Repository navigation
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
Conversation
… 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>
…th-gate-cli-sites
…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>
…th-gate-cli-sites
📓 Docs Drift CheckThis PR changes 3 package(s): 40 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 37 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: 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 ① Derived judgmentsEvery accept-set and public-surface change the diff implies, judged at this head.
② Semver levelRight. ③ Boundary flags
Everything in ① and ② holds. ③ carries two escalations to the seat, neither a finding against this head. Implemented-by: VERDICT: PASS Generated by Claude Code |
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'scustomSessionenrichment). REST'senforceAuthandHttpDispatcher.enforceAuthGaterefuse such a session with403and the gate'scode. Three groups of doors indomain:clipackages 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/coreexports, and refuses through its own declared error envelope with the REST seam's answer:403, the gate'scodeand the gate'smessage.@objectstack/runtime, the declared-endpoint fallback (runAppEndpointStep→applyEndpointPolicies, wired indispatcher-plugin.ts). The policy chain gains one step, between the meter andauthRequired: it asksevaluateAuthGateabout the session user a new policy dependency returns (EndpointPolicyContext.resolveGatedSessionUser, package-internal). The dispatcher plugin wires that dependency toreadGatedSessionUser, 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, anauthRequired: falseone 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 runsenforceAuthfor a signed-in caller, and the dispatcher gates before any domain resolves.@objectstack/rest, the direct mounts.registerPackageRoutes's publish door (refusePackageRequest) and the federation floor ofregisterExternalDatasourceRoutes(refuseFederationRequest) now refuse on theauthGatethat theRestServerresolver already lifts onto the context it hands them. Asked first, asenforceAuthasks it, so a held caller's capabilities are not consulted. The registrars' option types are package-internal; each gains the declaredauthGatefield on the context it already received.@objectstack/cloud-connection, the plugins that resolve a session.CloudConnectionPluginreads the session in one place,sessionFromAuthService, under both resolution modes (the host kernel'sauthin 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.MarketplaceInstallLocalPluginreads it in one place too, thegetSessionbridge insideresolveInstallPrincipal. 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'sexportsmap 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).
isAuthGateAllowlistedexempts by UNANCHORED substring/suffix — an object namedauthor a record with idhealthbypasses the ADR-0069 auth-policy gate #16839), so under the shipped prefix no declared path is allow-listed (/api/v1/apps/NS/auth/callbackis not). A declared path sits under the dispatcher prefix the host chooses, though, and a prefix that lands on an allow-listed boundary (anauthsegment) makes every declared path under it allow-listed. Handing the path in would let such a host exempt every endpoint it serves. The pin's anti-bypass case drives exactly that shape, after first asserting the path really is allow-listed. The staleauthRequiredcomment beside it, which still described the pre-isAuthGateAllowlistedexempts by UNANCHORED substring/suffix — an object namedauthor a record with idhealthbypasses the ADR-0069 auth-policy gate #16839 unanchored match, now says this.The two model seams, measured
enforceAuthHttpDispatcher.enforceAuthGate403, flat{ error: { code, message } }403,{ success: false, error: { code, message, httpStatus } }403with the gate'scodeandmessage, in each door's envelope503 SERVICE_UNAVAILABLE(the #13906 decision-2 window)503where 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 throw401(see the notes). Cloud-connection reads identity and posture in ONE read, so it has no such window: a failed read is the401it already gaveThe dispatcher's held and outage rows were driven against
HttpDispatcher.dispatchwith 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 servecomposes it (bootShowcase, with the three cloud-connection plugins mounted asservemounts 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,runtimeandrestrebuilt from them (the runtime dist was proven free of the change byablation-dist-preflight --absent), andcloud-connectionread 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.authRequiredendpoints, one a write)registerPackageRoutespublish,registerExternalDatasourceRoutes)CloudConnectionPlugin, the seven session doorsMarketplaceInstallLocalPlugin, all five doorsCloudConnectionPluginstatus read,MarketplaceProxyPluginControls 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 andMarketplaceProxyPlugin(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:
error.codeanderror.message, and nodata.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.authRequired: falseread, and the literal.../auth/callbackshape. The engine was never read and the flow never ran./healthanswers a held session as it answers an un-held one.503 SERVICE_UNAVAILABLE, and nothing ran.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 theRestServerresolver's real lift.401, unchanged.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.mjsin WRAP mode, after the fix was committed (treeb1ac258406). 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 emptygit diff HEAD. The pins import their subjects fromsrc, so nodist/leg applies.authGateDenialreturn replaced byvoid gateTests 10 failed | 6 passed (16): the 8 held boot cases and both anti-bypass cases090bc8891dd6== HEADendpoint.pathfor'')Tests 1 failed | 15 passed (16): the host-prefix case only. The literal.../auth/callbackcase stays green, as predicted, because the anchored allow-list does not exempt it090bc8891dd6== HEADauthGaterefusals short-circuitedTests 2 failed | 4 passed (6): both held casesae534aeb4a1d,6d6e073a14d3== HEADnullTests 6 failed | 6 passed (12): the fourCloudConnectionPluginheld cases and both install-local held casesb3271971823f,42622829a2fb== HEADOnly 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.@objectstack/runtimedist/index.d.ts/.d.cts1dbea2303158dba9, 310748 bytes1dbea2303158dba9, 310748 bytes@objectstack/restdist/index.d.ts/.d.cts991a8fc9cae955b5, 200956 bytes991a8fc9cae955b5, 200956 bytes@objectstack/cloud-connectiondist/index.d.ts/.d.ctsd8619b84e1b3ed00, 244655 bytesf09320554d95cabc, 244855 bytes@objectstack/coreimportAn 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'senforceAuthand the dispatcher. The doors fall into two groups:2faf9f2746, 2026-05-30), the three cloud-connection plugins (c26264f62eand83a895c543, 2026-05-23;998c4e4eaa, 2026-06-12), andCloudConnectionPlugin's session resolution (998c4e4eaa).refusePackageRequest(b295e4b36c, 2026-08-09), the federation floor (185c7bd51a, 2026-08-18, and9a1ed7a09e, 2026-08-20), and install-local'sresolveInstallPrincipal(e0695b5823, 2026-08-16). So did the declared-endpoint fallback (2649ccbe19anda70cd0aebe, 2026-08-04), which by design does not re-enterdispatch()and so never metenforceAuthGate.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:test:repo: runtime 3 / 808; rest 5 / 213 (2 skipped).pnpm --filter … typecheckexits 0 for all three. Each package's test program compiles its new pin file (tsc -p tsconfig.test.json --listFilesOnlynames it), andcheck:test-typecheckholds both ledgers unchanged.dispatch-gates --commands, 64 commands) and all run here, each exit 0.--rananswers: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN. That includescheck:adr-0087-registration(one declared-breaking changeset with its disposition),check-changeset-no-major,check:empty-changeset,check:route-envelopeandcheck:nul-bytes.check:dual-build-cjs-loadsfirst answered PREREQUISITE NOT MET for eight packages outside this diff. It measures, exit 0, since those were built (all cache hits).eslint.config.mjs, which never enables type-aware linting (noparserOptions.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 jsonover the 9 changed.tsfiles reports 9 files linted, none ignored, 0 errors and 0 warnings.875a7bd082; the ablations and the declaration check ran atb1ac258406. This head adds only two merges ofmain, neither touching a file of this diff.Acceptance notes
503. The dispatcher's ownenforceAuthGatefails open there, by its documented design. This PR's sites follow REST, as services: route families mounted straight on the HTTP server never consult the ADR-0069 authentication-policy gate, so a session held by enforced MFA or password expiry may still reach them #23178's storage family did. The dispatcher's seam is not touched here.401, the gate's outage included. Itscatchcollapses every throw to "no identity", where the publish door rethrows the branded outage and answers503. That is the [finding] two morecomputeExecCtxseams read "failed" and "not wired" as one value, and both feed authorization inputs — tenancy posture and the ADR-0069 auth gate #13906 family's concern, and it predates this PR. It is noted, not changed: a held session is still refused there, and a failed read is refused too, only under the anonymous code.ExecutionContext.authGate.codeis declared as a bare string. Both REST doors narrow it toErrorCodeat theirsendErrorcall, as services: route families mounted straight on the HTTP server never consult the ADR-0069 authentication-policy gate, so a session held by enforced MFA or password expiry may still reach them #23178's nested-envelope families do. Its one producer writes onlyPASSWORD_EXPIREDandMFA_REQUIRED.cloud-connection-plugin.ts's header asked the module to stay independent of@objectstack/coreso the move into the open package stays mechanical. That move has landed, and the package already depends on core. The module keeps its structural types and now takes one value from core, the gate's decision, because a second copy of that decision is the defect it exists to prevent. The header says so.minorfor all three packages, with the BREAKING banner and the bang summary, plus ADR-0087not-required (no-migration-prescription), as services: route families mounted straight on the HTTP server never consult the ADR-0069 authentication-policy gate, so a session held by enforced MFA or password expiry may still reach them #23178's half does. The diff narrows an accept set and moves no declaration, which is measured above.Generated by Claude Code