Repository navigation
Commit 08b38cb
Refs #23178 (datasource admin, share links, webhook redeliver, storage)
Clause-②: no (narrowing) — a session ADR-0069 holds out of protected
resources is refused by families 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 the
runtime dispatcher refuse such a session with `403` and the gate's
`code`. The route families mounted straight on the host server never
asked. This PR covers four of them. Each family 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`.
- **`@objectstack/service-datasource`,
`registerDatasourceAdminRoutes`.** The family's door,
`requireDatasourceAdmin`, now keeps the session user that its identity
resolution (`resolveAuthzContext`) already reads, and asks
`evaluateAuthGate` about it. The gate is asked first, as `enforceAuth`
asks it. A held caller is told what to remediate, and its capabilities
are not consulted. One session read serves all three decisions.
- **`@objectstack/plugin-sharing`, `registerShareLinkRoutes` plus
`SharingServicePlugin`.** The five routes are now mounted through one
family door, `mountFamilyRoute`. The door resolves the caller, refuses a
context that carries `authGate`, then runs the route's own work. Each
route's `catch` arm is unchanged, kept as the route's `failure` message.
The plugin's resolver now puts `normalizeAuthGate(session.user)` on the
`ExecutionContext` it returns. That is the declared `authGate` field,
lifted the way REST's `computeExecCtx` lifts it. Per the seat's ruling,
the two capability-token routes are refused too. This matches the
dispatcher's twin of the same routes, which already gates them. Those
two routes keep their no-store headers on the refusal.
- **`@objectstack/plugin-webhooks`,
`WebhookOutboxPlugin.serveRedeliver`.** The one redeliver door now asks
`evaluateAuthGate` about the session it already reads, before its 401
and before any replay. Both faces pass through it: the raw-app mount and
the `webhooks` service's `handleRedeliver`.
- **`@objectstack/service-storage`, `composeStorageRoutes`.** This is
the seat's option B. The family's ten registrations, the two
capability-token raw-transfer routes included, now go through one door,
`mountFamilyRoute` inside the package-internal `registerStorageFamily`.
`composeStorageRoutes` binds the posture there, beside the other storage
gates. That one composition serves both the plugin's own mount and the
host door `mountStorageRoutes`.
- The binding is a new package-internal reader,
`buildAuthGateSessionReader`. Only when `isAuthGateActive()` answers
true does it read the session; the door then asks `evaluateAuthGate`.
- When an active gate cannot read the session, the reader raises the
branded authorization-store outage, which the door relays as its
declared `503 SERVICE_UNAVAILABLE`. REST answers that case the same way
(#13906).
- This is a second session read beside the upload and download
resolver's, which the ruling accepts.
- The public `registerStorageRoutes` delegates with no reader, so a
direct call mounts the family exactly as before. Like the other storage
gates, this one is bound by the package's composition.
No new export, option, error code or spec key. The new names
(`ShareLinkFamilyRoute`, `registerStorageFamily`,
`StorageGatedSessionReader`, `buildAuthGateSessionReader`) are all
package-internal, and the package `exports` maps publish `"."` alone.
**`@objectstack/service-storage` declarations, measured
byte-identical.** I built the package twice, from `src` at `f6867e7125`
(before the storage change) and from `src` at `3a606af001` (with it).
`dist/index.d.ts` and `dist/index.d.cts` are 575615 bytes in both
builds, with sha256 prefix `fac6807e06fb7f07` in both, and `diff` exits
0 with 0 lines. `registerStorageFamily` and `StorageGatedSessionReader`
appear 0 times in the emitted `.d.ts`. `declare function
registerStorageRoutes(httpServer: IHttpServer, storage: IStorageService,
store: StorageMetadataStore, opts?: StorageRoutesOptions): void;` is
unchanged, and so are `StorageUploadSession` and `StorageRoutesOptions`.
### Request path: not passed, per family
Every family 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 reasons, per family:
- **Datasource admin:** the registrar's base path is the host's
argument. Passing it would let a family mounted under an allow-listed
prefix inherit an exemption it never declared. This is the reasoning the
#23142 contract review accepted for the settings floor.
- **Share links:** the base path is a host option (`basePath`), so the
same reason applies. The token routes are not on the allow-list either.
The allow-list admits only the approval act page and the inbound hook,
each by ruling.
- **Webhook redeliver:** the door's service face has no route of its
own, because the host chooses its URL. A path would only let the host's
choice exempt it.
- **Storage:** the base path is a host option (`basePath`, on both
`mountStorageRoutes` and the plugin), so the same reason applies as for
datasource admin. The raw-transfer token routes are not on the
allow-list.
## Measurement first (the card's first acceptance item)
The showcase was booted the way `objectstack serve` composes it, plus
the serve-only mounts the lean harness omits. Every registered route was
sent once un-held and once held. The hold was the admin force-change,
which gives the `PASSWORD_EXPIRED` code. Route-level readings are in the
lane's private channel only. Controls: the REST seam and the dispatcher
both answered the held session `403 PASSWORD_EXPIRED`, and an
allow-listed auth read answered `200`.
| Family | Reading | Here |
|---|---|---|
| datasource admin (`registerDatasourceAdminRoutes`) | reached | fixed |
| share links (`registerShareLinkRoutes`) | reached | fixed |
| webhook redeliver (`WebhookOutboxPlugin`) | reached | fixed (added to
the claim by the seat's amendment) |
| i18n (`I18nServicePlugin.registerI18nRoutes`) | **gated in front** |
dropped: the dispatcher plugin mounts the same three routes in
`start()`, ahead of this plugin's `kernel:ready` registration, so the
held session gets the dispatcher's gated answer and this plugin's own
mount is shadowed |
| storage (`composeStorageRoutes` / `registerStorageRoutes`) | reached |
fixed (seat ruling: option B, bound in the composition) |
| settings (`registerSettingsRoutes`) | reached | **not in this PR**,
see below |
## What remains on #23178
Settings only. Its door can reach the posture only through the exported
`SettingsContext`. The only channel that reaches its documented host
door, a host bridging `registerSettingsRoutes` onto its own app with its
own resolver, is an optional `authGate` on that exported type. That
widens the public surface, so it is `Clause-②: yes` and spec-lane work,
routed there by the seat. `registerSettingsRoutes` and
`SettingsServicePlugin` are not touched here, and #23178 remains open
for that row.
## Pins
Four new pin files, each built the same way. The **census is read off
the registration** with a count, so a route added later is driven held
the day it lands. Each pin covers three things:
- **Held:** every route, under both postures (`PASSWORD_EXPIRED`,
`MFA_REQUIRED`), is refused. The assertions are `status` 403,
`error.code` and the gate's `message`, plus no `data`, and the service
or replay was never reached.
- **Un-held control:** the same caller gets every route's own success
status, unchanged.
- **Preflight control:** an `OPTIONS` answer is identical held or
un-held, and is never the gate. For share links, the census asserts that
no `OPTIONS` route and no middleware are registered, so a preflight is
the host's answer.
The files:
- `service-datasource`, `admin-routes-auth-gate.pin.test.ts`: 11 routes,
45 tests, mounted on a real `HonoHttpServer`.
- `plugin-sharing`, `share-link-routes.auth-gate.pin.test.ts`: 5 routes,
18 tests. This includes a producer half that boots
`SharingServicePlugin` and refuses a held session end to end.
- `plugin-webhooks`, `webhook-redeliver-auth-gate.pin.test.ts`: 8 tests
across the mount and the service face, on a real `LiteKernel` boot over
the real messaging outbox. The census asserts exactly one route on the
host raw app.
- `service-storage`, `storage-routes.auth-gate.pin.test.ts`: 10 routes,
42 tests, driven through the composition (`mountStorageRoutes` over a
real `ObjectKernel`). The census asserts equality with
`STORAGE_ROUTE_LEDGER`, with nothing else registered.
- The storage adapter is proxied, and a held session must never touch
it.
- The un-held control compares every route against a mount with no
active gate, and adds a non-vacuity case where a member is served a
presigned upload.
- An outage arm checks that an active gate whose session read fails
answers `503 SERVICE_UNAVAILABLE` on every route.
`scripts/engine-double-contract.pinned.json` gains the webhook pin's
pinned `findOne` double, written by the gate's own `--write`.
### Negative-pin ablation
Each family's gate consultation was removed through
`scripts/ablation-replace.mjs` in WRAP mode, after the fix was committed
(`ee717acfa9` for the first three families, `3a606af001` for storage).
That family's pin file was run, then restored. Each restore is proven by
blob hash equal to HEAD and an empty `git diff HEAD`. The pins import
the families from `src`, so no `dist/` leg applies.
| Leg | Mutation (anchor 1 → 0) | Result | Restore |
|---|---|---|---|
| datasource admin | the `evaluateAuthGate` call replaced by `null` |
`Tests 22 failed \| 23 passed (45)`; `expected 200 to be 403`, `expected
201 to be 403`, `expected 204 to be 403`, `expected 'PERMISSION_DENIED'
to be 'MFA_REQUIRED'` | blob `fcb09ccaadbc` == HEAD |
| share links | the door's `refuseHeldSession` line removed | `Tests 11
failed \| 7 passed (18)` | blob `0e87b7d01033` == HEAD |
| webhook redeliver | the `evaluateAuthGate` call replaced by `null` |
`Tests 4 failed \| 4 passed (8)`; `expected 200 to be 403` | blob
`7d2f2da8cb21` == HEAD |
| storage (after `3a606af001`) | the door's `refuseHeldSession` line
removed | `Tests 30 failed \| 12 passed (42)` (20 held cases and 10
outage cases); `expected 400 to be 403`, `expected 404 to be 403`,
`expected 401 to be 403`, `expected 401 to be 503` | blob `a47ec9860bf5`
== HEAD |
Only the held cases went red in each leg. The un-held, preflight and
census controls stayed green, so the pins measure the gate and nothing
else.
## Verification (tree `5313a303cf`)
- `pnpm --filter … test`, all passed:
- service-storage: 53 files / 921 tests.
- service-datasource: 44 / 814.
- plugin-sharing: 42 / 1025.
- plugin-webhooks: 18 / 199.
- `pnpm --filter … typecheck` exits 0 for all four. Each package's
typecheck program compiles its new pin file, checked with `--listFiles`
and with `check:test-typecheck` respectively.
- Gates were re-derived after the storage diff (`dispatch-gates
--commands`) and all re-run on this head: 76 derived, 76 run, 0
NOT-MEASURED, 0 UNRUN (`--ran`, with exit codes recorded). Every command
exits 0, including the 4 roster families whose rosters sit under touched
directories.
- On the earlier head, `check:dual-build-cjs-loads` answered
PREREQUISITE NOT MET for eight unbuilt packages. It measures since they
were built.
- `check:engine-double-contract` passes with the ledger row noted under
Pins.
- ESLint, narrowed: the population is read from `eslint.config.mjs`,
which never enables type-aware linting, so this diff cannot move a
verdict on an untouched file. `eslint --no-inline-config --format json`
over the 10 changed `.ts` files reports 10 files linted, none ignored, 0
errors and 0 warnings.
## Acceptance notes
- `ExecutionContext.authGate.code` is declared as a bare string. Both
nested-envelope families narrow it to `ErrorCode` at their `sendError`
call. Its one producer writes only `PASSWORD_EXPIRED` and
`MFA_REQUIRED`, both members of the vocabulary. Typing the field against
the vocabulary would remove the casts; that is spec-lane work, not done
here.
- The share-link `messages` route now resolves its caller as `resolve`
already did. On an authorization-store outage, that route answers the
outage's declared status, where it used to ignore the store. This is the
same dependency its sibling already had.
- Semver: `minor` for all four packages, with the BREAKING banner and
the bang summary, plus ADR-0087 `not-required
(no-migration-prescription)`, following #23142. The diff narrows an
accept set and moves no declaration; for storage that is measured above.
- The i18n plugin's own registration of its three routes is shadowed on
the serve composition by the dispatcher plugin's mounts of the same
routes, so there are two owners of one route. This is noted and not
filed.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4d1e4a8 commit 08b38cb
12 files changed
Lines changed: 1305 additions & 71 deletions
File tree
- .changeset
- packages
- plugins
- plugin-sharing/src
- plugin-webhooks/src
- services
- service-datasource/src
- __tests__
- service-storage/src
- scripts
| 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 | + | |
| 25 | + | |
| 26 | + | |
| 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 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
0 commit comments