Skip to content

fix(runtime,rest,cloud-connection)!: the declared-endpoint fallback, REST's direct mounts and cloud-connection's session doors refuse a session the ADR-0069 policy holds (#23190) - #23242

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23190-auth-gate-cli-sites
Oct 13, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23190-auth-gate-cli-sites

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23190
Clause-②: no (narrowing) — a session ADR-0069 holds out of protected resources is refused by doors that answered it; core's existing exports only, no published schema or export moves.

What changed

ADR-0069 holds a signed-in user out of every protected resource while their password has expired or while enforced MFA is not yet enrolled. The posture rides on the session user (user.authGate, from plugin-auth's customSession enrichment). REST's enforceAuth and HttpDispatcher.enforceAuthGate refuse such a session with 403 and the gate's code. Three groups of doors in domain:cli packages resolve a session on their own and never asked. This PR closes all three, in triage's order. Each consults the gate once, at its own door, using only existing @objectstack/core exports, and refuses through its own declared error envelope with the REST seam's answer: 403, the gate's code and the gate's message.

  1. @objectstack/runtime, the declared-endpoint fallback (runAppEndpointStep → applyEndpointPolicies, wired in dispatcher-plugin.ts). The policy chain gains one step, between the meter and authRequired: it asks evaluateAuthGate about the session user a new policy dependency returns (EndpointPolicyContext.resolveGatedSessionUser, package-internal). The dispatcher plugin wires that dependency to readGatedSessionUser, which mirrors REST's posture read (RestServer.computeExecCtx) case for case: no read while no gate feature is active; a dedicated session read while one is; and the branded authorization-store outage (503 SERVICE_UNAVAILABLE) when an active gate cannot read the session. The step runs on every declared endpoint, an authRequired: false one included, because the target runs as the caller's execution context. That is also how the two model seams treat their public routes: REST's public-audience read still runs enforceAuth for a signed-in caller, and the dispatcher gates before any domain resolves.
  2. @objectstack/rest, the direct mounts. registerPackageRoutes's publish door (refusePackageRequest) and the federation floor of registerExternalDatasourceRoutes (refuseFederationRequest) now refuse on the authGate that the RestServer resolver already lifts onto the context it hands them. Asked first, as enforceAuth asks it, so a held caller's capabilities are not consulted. The registrars' option types are package-internal; each gains the declared authGate field on the context it already received.
  3. @objectstack/cloud-connection, the plugins that resolve a session.
    • CloudConnectionPlugin reads the session in one place, sessionFromAuthService, under both resolution modes (the host kernel's auth in single-environment mode, the environment's own kernel's in multi-tenant mode). The consult sits there, and each of the seven doors that resolve a session refuses on it before its own 401 and before any control-plane call.
    • MarketplaceInstallLocalPlugin reads it in one place too, the getSession bridge inside resolveInstallPrincipal. That bridge runs only when no API key admitted the caller. The consult sits after the shared resolver, on the same session it admitted. All five doors (the listing and the four mutating doors) answer a held session through one module-private refusal, after the 401 and before capabilities or the ledger.

No new export, option, error code or spec key, and no copy of the allow-list predicate. The new names (resolveGatedSessionUser, readGatedSessionUser, authGateDenial, ResolvedSession, InstallPrincipal, refuseHeldSession) are all package-internal, and each package's exports map publishes "." alone. The emitted declarations are measured below: byte-identical for runtime and rest, and identical for cloud-connection except one docblock's prose.

Request path: not passed, per site

Every site asks the decision with no request path, so the shared allow-list can grant no exemption. The empty path is the predicate's declared fail-closed input (#7898).

The two model seams, measured

case REST enforceAuth HttpDispatcher.enforceAuthGate the new sites
held session 403, flat { error: { code, message } } 403, { success: false, error: { code, message, httpStatus } } 403 with the gate's code and message, in each door's envelope
active gate, session read fails 503 SERVICE_UNAVAILABLE (the #13906 decision-2 window) fails open: the request proceeds 503 where the posture comes from a second read, as on REST: the fallback (its own read) and the publish door (it relays REST's resolver throw). The federation floor's catch-all answers that throw 401 (see the notes). Cloud-connection reads identity and posture in ONE read, so it has no such window: a failed read is the 401 it already gave
API-key caller, or no session not held not held not held: there is no session to hold

The dispatcher's held and outage rows were driven against HttpDispatcher.dispatch with a session whose read succeeds, then throws. The REST rows are the boot's control reading below plus REST's own pinned measurement (auth-gate-allowlist-fault-window.measurement.test.ts, CONTROL A).

Measurement first (the card's last acceptance item)

The showcase was booted the way objectstack serve composes it (bootShowcase, with the three cloud-connection plugins mounted as serve mounts them, pointed at a local control-plane stub). Every door was driven once anonymous, once by a signed-in admin, and once by the same admin held under each posture: an admin-issued force change (PASSWORD_EXPIRED) and enforced MFA, not enrolled, grace elapsed (MFA_REQUIRED). The before run had this PR's six site files restored to the base, runtime and rest rebuilt from them (the runtime dist was proven free of the change by ablation-dist-preflight --absent), and cloud-connection read from source, as that suite aliases it. The after run is the same boot at the fix (875a7bd082). Route-level readings are in the lane's private channel only.

Site group Before After
declared-endpoint fallback (the showcase's two authRequired endpoints, one a write) reached: the held session got the un-held answer refused, both postures
REST direct mounts (registerPackageRoutes publish, registerExternalDatasourceRoutes) reached refused, both postures
CloudConnectionPlugin, the seven session doors reached refused, both postures
MarketplaceInstallLocalPlugin, all five doors reached refused, both postures
CloudConnectionPlugin status read, MarketplaceProxyPlugin no session resolved: held, un-held and anonymous got the same answer unchanged (see below)

Controls on the same boot, in both runs: the REST data list and the dispatcher's notifications refused each held posture with the gate's code, and an allow-listed auth read answered 200. No site was gated in front, so none is dropped. An anonymous caller and an un-held caller got the same answers before and after on every door.

Not changed: the two doors that resolve no session. The card's acceptance places the consult "where they resolve their session". CloudConnectionPlugin's status read and MarketplaceProxyPlugin (the public catalog, forwarded with no credential) resolve none, and both answered the held session exactly as they answer an anonymous caller. Nothing is read or written as the held user there. Gating them would add a session read to two public doors, only to refuse a caller who is served without the cookie anyway. This is raised to the seat as an open question rather than decided here.

Pins

One pin file per site group, beside its site. Each covers the card's three cases under both postures:

  • Held: the status, error.code and error.message, and no data.
  • Un-held: the same grants without a posture, served as before.
  • Allow-listed: the third case, per site.

The pin files:

  • runtime, endpoint-policy.auth-gate.pin.test.ts (16 tests). This is a real LiteKernel with the real Hono transport and the real dispatcher plugin, driven over a socket, with the services stubbed at the kernel boundary.
    • Held, on four declared endpoints: a read, a write (a flow), an authRequired: false read, and the literal .../auth/callback shape. The engine was never read and the flow never ran.
    • Un-held, and anonymous: unchanged.
    • Allow-listed: the dispatcher's /health answers a held session as it answers an un-held one.
    • Outage: 503 SERVICE_UNAVAILABLE, and nothing ran.
    • Gate off: the posture is never read.
    • Anti-bypass: the step under a host prefix whose declared path IS allow-listed, a precondition the case asserts first, still refuses the held session, and the target never runs.
  • rest, direct-mount-auth-gate.pin.test.ts (6 tests). This is the real composition (createRestApiPlugin(...).start(ctx)), so the posture reaches the doors through the RestServer resolver's real lift.
    • Held: all six routes refused, and no service ran. The caller holds every capability the family asks for, so the refusal is the gate's alone.
    • Un-held: each route's own success status, and each service ran.
    • Anonymous: 401, unchanged.
    • Allow-listed: the discovery document, the one allow-listed route this composition mounts, answers held and un-held identically.
    • Outage: the publish door answers 503 SERVICE_UNAVAILABLE.
  • cloud-connection, cloud-connection-auth-gate.pin.test.ts (12 tests).
    • CloudConnectionPlugin, in both resolution modes. Held: all seven session doors refused, nothing reached the control plane, and the credential was not cleared. Un-held: served. Anonymous: 401. Census: eight routes, none allow-listed, and the status read reads no session.
    • MarketplaceInstallLocalPlugin. Held: all five doors refused, and no registry, schema, seed or delete effect fired. Un-held: every door served. Anonymous: 401. Census: five routes, none allow-listed.

Negative-pin ablation

Each site group's consult was removed through scripts/ablation-replace.mjs in WRAP mode, after the fix was committed (tree b1ac258406). The two-file groups nest one WRAP inside the other. The group's pin file was run, then everything was restored. Each restore is proven by blob hash equal to HEAD and an empty git diff HEAD. The pins import their subjects from src, so no dist/ leg applies.

Leg Mutation (each anchor 1 → 0) Result Restore
fallback consult the authGateDenial return replaced by void gate Tests 10 failed | 6 passed (16): the 8 held boot cases and both anti-bypass cases blob 090bc8891dd6 == HEAD
fallback anti-bypass the declared path handed to the allow-list (endpoint.path for '') Tests 1 failed | 15 passed (16): the host-prefix case only. The literal .../auth/callback case stays green, as predicted, because the anchored allow-list does not exempt it blob 090bc8891dd6 == HEAD
REST direct mounts both authGate refusals short-circuited Tests 2 failed | 4 passed (6): both held cases blobs ae534aeb4a1d, 6d6e073a14d3 == HEAD
cloud-connection both consults replaced by null Tests 6 failed | 6 passed (12): the four CloudConnectionPlugin held cases and both install-local held cases blobs b3271971823f, 42622829a2fb == HEAD

Only held cases went red in each leg. The un-held, anonymous, allow-listed, outage, gate-off and census controls stayed green, so the pins measure the gate and nothing else. The outage case stays green under the fallback leg by construction: the read throws before the consult.

Declarations, measured

Each package was built twice on tree b1ac258406: once with this PR's six site files restored to the merge base (75cada0ea6), and once at HEAD. Hashes are sha256 prefixes.

Package Merge base HEAD Result
@objectstack/runtime dist/index.d.ts / .d.cts 1dbea2303158dba9, 310748 bytes 1dbea2303158dba9, 310748 bytes identical
@objectstack/rest dist/index.d.ts / .d.cts 991a8fc9cae955b5, 200956 bytes 991a8fc9cae955b5, 200956 bytes identical
@objectstack/cloud-connection dist/index.d.ts / .d.cts d8619b84e1b3ed00, 244655 bytes f09320554d95cabc, 244855 bytes declarations identical. The one hunk is two prose lines of the class's docblock becoming four, the note on the new @objectstack/core import

An earlier shape of the install-local refusal was a private class member, and the emitted declarations named it. It is a module-private function now, so no member is emitted.

Responsibility

No single change introduced the gap. The gate (ADR-0069 D1, ce0b4f61e2, 2026-06-28) was wired into the two seams that existed then, REST's enforceAuth and the dispatcher. The doors fall into two groups:

  • Predate the gate: the federation registrar (2faf9f2746, 2026-05-30), the three cloud-connection plugins (c26264f62e and 83a895c543, 2026-05-23; 998c4e4eaa, 2026-06-12), and CloudConnectionPlugin's session resolution (998c4e4eaa).
  • Built after the gate, beside it: the identity floors were added later without the gate. That is the publish door's refusePackageRequest (b295e4b36c, 2026-08-09), the federation floor (185c7bd51a, 2026-08-18, and 9a1ed7a09e, 2026-08-20), and install-local's resolveInstallPrincipal (e0695b5823, 2026-08-16). So did the declared-endpoint fallback (2649ccbe19 and a70cd0aebe, 2026-08-04), which by design does not re-enter dispatch() and so never met enforceAuthGate.

Each of the later changes ported the anonymous floor and the capability check from the seam it mirrored, and not the gate beside them.

Verification (tree faae1c0de1)

  • pnpm --filter … test, all passed:
    • runtime: 367 files / 5221 tests (19 skipped).
    • rest: 278 / 5808 (326 skipped).
    • cloud-connection: 45 / 552.
    • test:repo: runtime 3 / 808; rest 5 / 213 (2 skipped).
  • pnpm --filter … typecheck exits 0 for all three. Each package's test program compiles its new pin file (tsc -p tsconfig.test.json --listFilesOnly names it), and check:test-typecheck holds both ledgers unchanged.
  • Gates were re-derived on this head (dispatch-gates --commands, 64 commands) and all run here, each exit 0. --ran answers: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN. That includes check:adr-0087-registration (one declared-breaking changeset with its disposition), check-changeset-no-major, check:empty-changeset, check:route-envelope and check:nul-bytes.
    • check:dual-build-cjs-loads first answered PREREQUISITE NOT MET for eight packages outside this diff. It measures, exit 0, since those were built (all cache hits).
  • ESLint, narrowed. The population is read from eslint.config.mjs, which never enables type-aware linting (no parserOptions.project, no typed rules), and whose per-file baselines this diff does not touch, so the diff cannot move a verdict on an untouched file. eslint --no-inline-config --format json over the 9 changed .ts files reports 9 files linted, none ignored, 0 errors and 0 warnings.
  • The pins were also run alone at 875a7bd082; the ablations and the declaration check ran at b1ac258406. This head adds only two merges of main, neither touching a file of this diff.

Acceptance notes


Generated by Claude Code

… declared-endpoint fallback, the REST direct mounts and the cloud-connection session doors

A session the authentication policy holds (an expired password, enforced
MFA not yet enrolled) is now refused with 403 and the gate's own code at
three door groups that resolved a session without asking the gate:

- runtime: the declared-endpoint fallback's policy chain asks
  evaluateAuthGate, with no request path, before authRequired, on a
  dedicated session read that runs only while a gate feature is active
  and answers 503 when an active gate cannot read the session.
- rest: the publish door and the federation floor read the authGate
  their resolved execution context already carries.
- cloud-connection: CloudConnectionPlugin's session resolution and
  MarketplaceInstallLocalPlugin's admission consult the gate once and
  refuse before any door logic.

Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6
Co-authored-by: Claude <noreply@anthropic.com>
… door group

One pin file per site group. Each drives a held session under both
postures (refused 403 with the gate's code and message, no effect), the
same grants without a posture (served unchanged), and the allow-listed
case: the dispatcher's /health and REST's discovery document answer a
held session as an un-held one, and the fallback's anti-bypass case
proves a declared path under an allow-listed host prefix is still
refused. The fallback and the publish door also pin the 503 answer for
an active gate whose session read fails.

Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6
Co-authored-by: Claude <noreply@anthropic.com>
…ud-connection doors

minor for each released package, BREAKING under the launch-window
convention, with the ADR-0087 not-required disposition.

Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6
Co-authored-by: Claude <noreply@anthropic.com>
…gh a module-private refusal

The refusal moves out of the exported plugin class into a module-private
function, so the emitted declarations gain no private member. Both door
paths (the listing and the four mutating doors) still answer the 401
first and the held session second, on the one consult in
resolveInstallPrincipal.

Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/cloud-connection, @objectstack/rest, @objectstack/runtime, touching 37 documentable anchor(s).

40 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 197fbe2c04edbe4cf0ffc691dc59e4fcf8d9ba47.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 37 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 197fbe2c04edbe4cf0ffc691dc59e4fcf8d9ba47 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 051c475fe75494b350771faebdd7611ac9586d72 — the merge of head faae1c0de170a2c87a97e334e018f8a048834d0e into base 197fbe2c04edbe4cf0ffc691dc59e4fcf8d9ba47, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 051c475fe75494b350771faebdd7611ac9586d72 && git checkout 051c475fe75494b350771faebdd7611ac9586d72
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 197fbe2c04edbe4cf0ffc691dc59e4fcf8d9ba47 faae1c0de170a2c87a97e334e018f8a048834d0e && git checkout -B drift-repro 197fbe2c04edbe4cf0ffc691dc59e4fcf8d9ba47 && git merge --no-ff faae1c0de170a2c87a97e334e018f8a048834d0e

node scripts/docs-audit/affected-docs.mjs --json 197fbe2c04edbe4cf0ffc691dc59e4fcf8d9ba47

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 197fbe2c04edbe4cf0ffc691dc59e4fcf8d9ba47 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: faae1c0de170a2c87a97e334e018f8a048834d0e
Local-runs: none

Rendered 2026-10-13T11:22Z by the adopting seat's session, read-only, against AGENTS.md Prime Directives #10 and #12, Route and surface ownership, Post-Task Checklist step 3, and ADR-0069. Read: the net diff against main (merge base 01a3421f42; 10 files, +1325 / -28, equal to the three-dot diff), the six branch commits and the two merges of main, the card thread (body, triage 6140946784, claim 6141924281, report 6144103571, and the ACCEPT 6144168932 read as a claim to test), ruling 6141053341 with PR #23200's landed changeset and doors on origin/main, the slice-1 records on PR #23133 (6138381862, 6139157658), PR #23242's body and file list, the head's check-runs, and at this head the sources the diff leans on: core's auth-gate.ts, authz-store-unavailable.ts and resolve-authz-context.ts; runtime's api-endpoint-step.ts, http-dispatcher.ts and error-envelope.ts; rest's rest-server.ts, rest-api-plugin.ts and direct-mount-composition.ts; cloud-connection's marketplace-proxy-plugin.ts; the three package entries and exports maps. Nothing was built, run or re-run; the built-declaration claims are judged from the entries and the module shapes, not from a rebuild. This record stays at function level (triage 6140946784): sites, functions and the class of session refused. The route-level boot readings are the seat's, relied on and never quoted.

① Derived judgments

Every accept-set and public-surface change the diff implies, judged at this head.

  1. One decision, no copy — right. Five sites, one predicate. The fallback (applyEndpointPolicies, step ②), CloudConnectionPlugin's sessionFromAuthService and MarketplaceInstallLocalPlugin.resolveInstallPrincipal call core's evaluateAuthGate; refusePackageRequest and refuseFederationRequest read the authGate that RestServer.computeExecCtx lifts through core's normalizeAuthGate, the field enforceAuth reads. No site spells the allow-list, touches a route list or re-derives the decision; the pins import isAuthGateAllowlisted only to assert census facts. No new export or exported field: all three exports maps publish . alone; packages/runtime/src/index.ts takes createDispatcherPlugin and DispatcherPluginConfig from dispatcher-plugin.js and nothing from endpoint-policy.js, so EndpointPolicyContext.resolveGatedSessionUser is package-internal; packages/rest/src/index.ts re-exports neither registrar module nor direct-mount-composition.js (only createRestApiPlugin and its config type), so the authGate field on each registrar's option literal never reaches the entry; packages/cloud-connection/src/index.ts already exported both classes, and the new names are closures inside start() (sessionFromAuthService, resolveSession, the plugin's refuseHeldSession), a module-private function (install-local's refuseHeldSession), unexported interfaces (ResolvedSession, InstallPrincipal) and a private member (resolveInstallPrincipal) whose emitted stub carries no type. That is consistent with the measured declarations: runtime and rest byte-identical, cloud-connection differing by one prose hunk. One correction of label only: the hunk the report calls "the class docblock" is the module's file-header comment (cloud-connection-plugin.ts lines 23 to 28, plus the one-line note at 55); it is prose either way, and no declaration moves.
  2. evaluateAuthGate(user, '') — right, and fail-closed by construction. isAuthGateAllowlisted opens with if (!rawPath) return false; ([Decision] Fail-close isAuthGateAllowlisted's "no path ⇒ exempt" default (Option 2 of #7432) #7898), before the anchored route match and before the parameterised rows, so an empty path can never earn an exemption and no declared path, under any host prefix, can exempt itself; the anti-bypass pin drives the host-prefix shape after asserting its precondition. Gating authRequired: false endpoints is right under ADR-0069 D3 ("gated out of all protected resources") and under both model seams: the fallback runs the target as the caller's execution context (resolveRequestScope), so a held session would read and write as its user; REST skips enforceAuth only for anonymousPublicRead, which is !context?.userId AND a public-audience read (rest-server.ts line 5505), so a signed-in caller is gated on a public read too; the dispatcher asks enforceAuthGate inside dispatch() before any domain resolves. The new session read is safe: readGatedSessionUser reads nothing unless isAuthGateActive() answers true (a false or throwing probe yields undefined with no read); reads once with inProcessSessionReadInput (no cookie renewal, auth: server-side auth.api.getSession reads renew the session without forwarding the renewed cookie, so the browser cookie expires before the session (split session) #22258); and on a failed read throws AuthzStoreUnavailableError('auth_gate', err), whose own status is 503 and code is SERVICE_UNAVAILABLE. runAppEndpointStep has no catch around applyEndpointPolicies, so the throw reaches the fallback's errorResponse, which reads err.status; the outage pin reads 503 with nothing run. The read hands session?.user to the predicate and nowhere else; the execution context the target runs as is built by resolveRequestScope, untouched, so nothing is minted or widened. Placement between the meter and authRequired mirrors enforceAuth's gate-before-floor order, and the separate read is necessary: resolveSessionPrincipalId swallows every failure as anonymous, and http-dispatcher.ts never lifts authGate onto the context it resolves, so there was nothing to reuse.
  3. REST's direct mounts — right. Both registrars receive the one resolver rest-api-plugin.ts binds (restServer.resolvePackageRouteExecutionContext, which is resolveExecCtx over computeExecCtx), so the authGate they read is the one enforceAuth reads. refusePackageRequest is the publish handler's first statement and refuses on ctx?.authGate before shouldDenyAnonymous and before the capability check; refuseFederationRequest refuses before its anonymous floor and its capability check, on every handler that opens with it (five). Each writes sendError(res, 403, code, message), the module's declared nested envelope (check:route-envelope pins package-routes at zero unenveloped), which is the door's own, as the card and claim require; REST's enforceAuth keeps its flat body on its own seam. Neither module registers an OPTIONS handler, so a preflight never enters the refusal and no counterpart to enforceAuth's OPTIONS skip is owed.
  4. cloud-connection — right. CloudConnectionPlugin: every session on the surface comes from resolveSession, whose two arms (the host kernel's auth in single-environment mode, the environment kernel's auth in multi-tenant mode) both end in sessionFromAuthService, where the one consult sits; the module admits no inbound API key (the bearers it builds are outbound, to the control plane), and each of the seven session doors refuses on session?.authGate before its 401 and before any control-plane call. MarketplaceInstallLocalPlugin: all five routes reach refuseHeldSession through one of two call sites, the listing door's own resolveInstallPrincipal call and requireInstallCapability for the four mutating doors; the module's other session reads (resolveActiveOrgId) sit in post-admission helpers (sampleDataInScope, handlePurge after its admission, applySideEffects). An API-key caller is unaffected, as on REST: core's resolveAuthzContext resolves the key first and calls the getSession bridge only when no key resolved a user, so sessionUser stays unset and the predicate answers null. Because both plugins read identity and posture in one read, a failed read has no identity either and stays the 401 it already was; that is PR fix(services)!: the datasource admin, share-link, webhook redeliver and storage families refuse a session the ADR-0069 policy holds (#23178) #23200's datasource-admin shape (one read, 401), while the fallback and the publish door are its storage shape (a second read, 503).
  5. Ruling A on the two no-session doors — right. CloudConnectionPlugin's status read makes no session read (the census pin asserts zero reads on it), and marketplace-proxy-plugin.ts contains no session or auth-service read at all. ADR-0069 D3 holds a session out of PROTECTED resources; a door that answers a cookieless caller identically protects nothing by session, and a held session there gets exactly the anonymous answer, so no held-session path is left open on either. The card places the consult "where they resolve their session", and they resolve none. Fixes #23190 stays right; the PR body and the changeset both say which two doors are unchanged and why, so nothing is buried.
  6. Parity with PR fix(services)!: the datasource admin, share-link, webhook redeliver and storage families refuse a session the ADR-0069 policy holds (#23178) #23200 — right. Same predicate call (evaluateAuthGate(user, '')), same answer (403 with the gate's code and message in the family's declared envelope), same order (the gate, then the anonymous floor, then the capability), the same 503 posture where a second read exists and the same single-read 401 where it does not. The two halves have not diverged.

② Semver level

Right. .changeset/23190-auth-gate-cli-sites.md (24 lines): minor for @objectstack/runtime, @objectstack/rest and @objectstack/cloud-connection, all three publishing (private absent, 18.0.0-next.0); the body's first line is fix(runtime,rest,cloud-connection)!: …; Clause-②: no (narrowing) exactly once, with #23200's continuation; exactly one adr-0087: marker, not-required (no-migration-prescription), in the HTML-comment form; the launch-window BREAKING line exactly once; and a "What changes for you" paragraph, which is the migration text step 3 asks a breaking changeset to carry. That is PR #23200's changeset line for line in shape and category, and the shape the #23133 records (6138381862 remedy, 6139157658 PASS) settled for an accept-set narrowing. The marker's facts hold on the diff: no path under packages/spec, no removed exported declaration (the removed lines are a comment rewrite, an inline return type moved into the unexported InstallPrincipal, and a closure's return type), and the new fields live on package-internal types only (① item 1). The PR title carries the bang; body line 1 is Fixes #23190, line 2 the Clause-② line with its continuation. Check Changeset on this head is success.

③ Boundary flags

  • dispatcher-plugin.ts touched — under the claim's condition: the fallback's session read is wired there (readGatedSessionUser and the resolveGatedSessionUser binding). api-endpoint-step.ts and marketplace-proxy-plugin.ts are untouched; no ledger rows, and check:engine-double-contract is green. Right.
  • Deepened history — an object-store fetch on the shared checkout, no file edit; the lost race on refs/remotes/origin/main is a sibling's fetch, not this PR's. Carried; nothing owed.
  • Two temporary measurement files — deleted before commit; the PR's ten files carry neither. Checklist step 7 honoured.
  • Two merges of main (8fe7a39bb2, faae1c0de1) — each is byte-identical to its branch parent over the ten paths (both diffs empty); what main brought under the three packages (domains/meta.ts and others) is disjoint. Right.
  • Commit trailers — the model-free pair, as the fix(rest)!: PUT and the five by-name /meta sub-doors declare closed query sets, so ?member= is refused, never dropped (#23072, RestServer half) #23133 record already ruled. No action.
  • Disclosure — the PR body, changeset, report and ACCEPT stay at function level; the pin files name the routes they drive, which is source, not a reach reading.
  • open_questions[0] — ruled A by the seat; judged right in ① item 5.
  • Carried note 1, the dispatcher's own seam fails open — verified: HttpDispatcher.enforceAuthGate swallows the session read's rejection and its outer catch returns null ("fail-open — never break dispatch on a gate hiccup"), so an active gate whose read throws admits the request on every dispatcher route, while REST answers 503 in the same window by the [finding] two more computeExecCtx seams read "failed" and "not wired" as one value, and both feed authorization inputs — tenancy posture and the ADR-0069 auth gate #13906 ruling (decision 2 option B), on the reasoning that a declared enforcement must not vanish silently. Not a defect this PR must fix: the dispatcher's seam is outside the claim's surface, and the card's acceptance does not name it. ⛔ Not rightly carried either. It is a measured divergence between the two model seams on exactly the window the maintainer ruled for REST; core's auth-gate.ts header says the seams "can never drift on what is blocked"; and the dev drove it through dispatch() and read 200. Under Prime Directive chore: version packages #10 that is a contract reading with its evidence named, and an acceptance note is where it will not be acted on. Escalated: the seat files it as a card (domain:cli, http-dispatcher.ts, enforceAuthGate's outage window), carrying the dev's measurement and the [finding] two more computeExecCtx seams read "failed" and "not wired" as one value, and both feed authorization inputs — tenancy posture and the ADR-0069 auth gate #13906 ruling text, for triage to decide whether that ruling extends to the dispatcher.
  • Carried note 2, the federation floor answers an outage 401 — verified: computeExecCtx's outer catch rethrows the branded outage (rest-server.ts line 3357, rethrowAuthzStoreUnavailable), refusePackageRequest relays it as 503 through the same helper, and refuseFederationRequest's bare catch, which sets authz to undefined, turns the same throw into 401 UNAUTHENTICATED. Core's rethrowAuthzStoreUnavailable header names that exact net (the catch every fail-closed seam between the resolver and a door should use in place of one that returns undefined) and that exact disguise. Two registrars mounted by one composition step, on one resolver, answer one outage two ways. It predates this PR (the floor landed in August, before [finding] two more computeExecCtx seams read "failed" and "not wired" as one value, and both feed authorization inputs — tenancy posture and the ADR-0069 auth gate #13906), and this PR's gate adds nothing to the window. Not this PR's to fix: outside the card's acceptance, and Prime Directive chore: version packages #10 forbids the scope creep. ⛔ Not rightly carried: the contract text and the code are both named, which is what the filing gate asks for. Escalated: the seat files it as a card (domain:cli, external-datasource-routes.ts, refuseFederationRequest's catch), a one-site conversion onto rethrowAuthzStoreUnavailable with the REST pin's publish outage case as its template.
  • Check-runs on this head at render time: 30 success, 8 skipped (Console Pin Gate, the live-database gates, the opt-in tarball smoke and the label and size jobs, as this diff's paths warrant), 0 failure; Test Core (3/6) was still in_progress and is not judged here. Check Changeset, Governed Surface Queue Guard, every Lint and Type Check run, Build Core, Dogfood Regression Gate, Part-of PR must not also close its card and The card this PR closes must claim this branch are success. The landing waits for that shard as for every other check; nothing in it can move a judgment above, since the three suites and the pins also ran at this head in the report.
  • Not governed — none of the ten paths is a governed surface; Governed Surface Queue Guard agrees.

Everything in ① and ② holds. ③ carries two escalations to the seat, neither a finding against this head.

Implemented-by: claude/issue-23190-auth-gate-cli-sites
Reviewed-by: session_01VoSxBQujKLZKPwK2u5ehQ6

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 13, 2026 11:26
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 13, 2026 11:26
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 13, 2026
Merged via the queue into main with commit d9d7edd Oct 13, 2026
48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23190-auth-gate-cli-sites branch October 13, 2026 11:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants