Repository navigation
Commit d9d7edd
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).
- **Declared-endpoint fallback:** the card's trap. The allow-list is
anchored at a mount boundary (#16839), so under the shipped prefix no
declared path is allow-listed (`/api/v1/apps/NS/auth/callback` is not).
A declared path sits under the dispatcher prefix the host chooses,
though, and a prefix that lands on an allow-listed boundary (an `auth`
segment) 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 stale `authRequired` comment beside it,
which still described the pre-#16839 unanchored match, now says this.
- **REST direct mounts:** each registrar's base path is the host's
argument, as for #23178's datasource-admin family. Neither route is a
remediation or bootstrap route.
- **Cloud-connection:** neither plugin mounts an allow-listed route (the
pin's census asserts it for all 13 routes).
## 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
- **The two model seams disagree on the outage window.** REST answers an
active gate whose session read fails with `503`. The dispatcher's own
`enforceAuthGate` fails open there, by its documented design. This PR's
sites follow REST, as #23178's storage family did. The dispatcher's seam
is not touched here.
- **The federation floor answers any resolver throw `401`, the gate's
outage included.** Its `catch` collapses every throw to "no identity",
where the publish door rethrows the branded outage and answers `503`.
That is the #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.code` is declared as a bare string. Both
REST doors narrow it to `ErrorCode` at their `sendError` call, as
#23178's nested-envelope families do. Its one producer writes only
`PASSWORD_EXPIRED` and `MFA_REQUIRED`.
- `cloud-connection-plugin.ts`'s header asked the module to stay
independent of `@objectstack/core` so 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.
- Semver: `minor` for all three packages, with the BREAKING banner and
the bang summary, plus ADR-0087 `not-required
(no-migration-prescription)`, as #23178's half does. The diff narrows an
accept set and moves no declaration, which is measured above.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6f4c518 commit d9d7edd
10 files changed
Lines changed: 1325 additions & 28 deletions
File tree
- .changeset
- packages
- cloud-connection/src
- rest/src
- runtime/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
23 | 23 | | |
24 | 24 | | |
25 | 25 | | |
26 | | - | |
27 | | - | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
28 | 30 | | |
29 | 31 | | |
30 | 32 | | |
| |||
50 | 52 | | |
51 | 53 | | |
52 | 54 | | |
53 | | - | |
| 55 | + | |
54 | 56 | | |
55 | 57 | | |
56 | 58 | | |
| |||
78 | 80 | | |
79 | 81 | | |
80 | 82 | | |
| 83 | + | |
81 | 84 | | |
82 | 85 | | |
83 | 86 | | |
84 | 87 | | |
85 | 88 | | |
86 | 89 | | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
87 | 99 | | |
88 | 100 | | |
89 | 101 | | |
| |||
201 | 213 | | |
202 | 214 | | |
203 | 215 | | |
204 | | - | |
| 216 | + | |
205 | 217 | | |
206 | 218 | | |
207 | 219 | | |
208 | 220 | | |
209 | 221 | | |
210 | 222 | | |
211 | | - | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
212 | 236 | | |
213 | 237 | | |
214 | | - | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
215 | 250 | | |
216 | 251 | | |
217 | 252 | | |
| |||
294 | 329 | | |
295 | 330 | | |
296 | 331 | | |
| 332 | + | |
297 | 333 | | |
298 | 334 | | |
299 | 335 | | |
| |||
349 | 385 | | |
350 | 386 | | |
351 | 387 | | |
| 388 | + | |
352 | 389 | | |
353 | 390 | | |
354 | 391 | | |
| |||
447 | 484 | | |
448 | 485 | | |
449 | 486 | | |
| 487 | + | |
450 | 488 | | |
451 | 489 | | |
452 | 490 | | |
| |||
495 | 533 | | |
496 | 534 | | |
497 | 535 | | |
| 536 | + | |
498 | 537 | | |
499 | 538 | | |
500 | 539 | | |
| |||
545 | 584 | | |
546 | 585 | | |
547 | 586 | | |
| 587 | + | |
548 | 588 | | |
549 | 589 | | |
550 | 590 | | |
| |||
607 | 647 | | |
608 | 648 | | |
609 | 649 | | |
| 650 | + | |
610 | 651 | | |
611 | 652 | | |
612 | 653 | | |
| |||
644 | 685 | | |
645 | 686 | | |
646 | 687 | | |
| 688 | + | |
647 | 689 | | |
648 | 690 | | |
649 | 691 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
112 | 112 | | |
113 | 113 | | |
114 | 114 | | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
115 | 119 | | |
116 | 120 | | |
117 | 121 | | |
| |||
529 | 533 | | |
530 | 534 | | |
531 | 535 | | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
| 563 | + | |
| 564 | + | |
| 565 | + | |
| 566 | + | |
| 567 | + | |
| 568 | + | |
| 569 | + | |
| 570 | + | |
| 571 | + | |
532 | 572 | | |
533 | 573 | | |
534 | 574 | | |
| |||
1721 | 1761 | | |
1722 | 1762 | | |
1723 | 1763 | | |
| 1764 | + | |
| 1765 | + | |
1724 | 1766 | | |
1725 | 1767 | | |
1726 | 1768 | | |
| |||
3280 | 3322 | | |
3281 | 3323 | | |
3282 | 3324 | | |
3283 | | - | |
3284 | | - | |
3285 | | - | |
3286 | | - | |
3287 | | - | |
3288 | | - | |
3289 | | - | |
3290 | | - | |
| 3325 | + | |
3291 | 3326 | | |
3292 | 3327 | | |
3293 | 3328 | | |
| |||
3301 | 3336 | | |
3302 | 3337 | | |
3303 | 3338 | | |
| 3339 | + | |
| 3340 | + | |
| 3341 | + | |
| 3342 | + | |
| 3343 | + | |
| 3344 | + | |
| 3345 | + | |
3304 | 3346 | | |
3305 | 3347 | | |
3306 | 3348 | | |
| |||
3313 | 3355 | | |
3314 | 3356 | | |
3315 | 3357 | | |
3316 | | - | |
| 3358 | + | |
| 3359 | + | |
| 3360 | + | |
3317 | 3361 | | |
3318 | 3362 | | |
3319 | 3363 | | |
| |||
3325 | 3369 | | |
3326 | 3370 | | |
3327 | 3371 | | |
| 3372 | + | |
| 3373 | + | |
| 3374 | + | |
| 3375 | + | |
| 3376 | + | |
| 3377 | + | |
| 3378 | + | |
3328 | 3379 | | |
3329 | 3380 | | |
3330 | 3381 | | |
3331 | 3382 | | |
3332 | 3383 | | |
| 3384 | + | |
3333 | 3385 | | |
3334 | 3386 | | |
3335 | 3387 | | |
| |||
3381 | 3433 | | |
3382 | 3434 | | |
3383 | 3435 | | |
| 3436 | + | |
| 3437 | + | |
3384 | 3438 | | |
3385 | 3439 | | |
3386 | 3440 | | |
| |||
0 commit comments