Skip to content

Commit 08b38cb

Browse files
fix(services)!: the datasource admin, share-link, webhook redeliver and storage families refuse a session the ADR-0069 policy holds (#23178) (#23200)
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
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/service-datasource': minor
3+
'@objectstack/plugin-sharing': minor
4+
'@objectstack/plugin-webhooks': minor
5+
'@objectstack/service-storage': minor
6+
---
7+
8+
fix(services)!: the datasource admin, share-link, webhook redeliver and storage families refuse a session the ADR-0069 authentication policy holds
9+
10+
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.
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) A runtime authentication narrowing at four families of routes mounted directly on the host server, not a metadata change. No spec key, export, option, config field, response field or stored shape is removed, renamed or re-shaped, and a session the policy does not hold receives the same answers as before, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. What narrows is which sessions these families serve: a session whose user carries the ADR-0069 posture (an expired password, or enforced MFA not yet enrolled) is now refused with the gate's 403 before any of the family's logic runs. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers these routes and this diff adds none (not registered / already-registered); and no published interface or type changes (not runtime-interface-only / type-surface-only). -->
13+
14+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
15+
16+
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, until they remediate. REST and the runtime dispatcher already refuse such a session. Four families of routes mounted straight on the host server never asked, so they served it as an ordinary signed-in caller:
17+
18+
- **`@objectstack/service-datasource`**: the datasource admin family (`registerDatasourceAdminRoutes`). Its door now asks the shared decision (`evaluateAuthGate`, `@objectstack/core`) about the session it already reads, before the anonymous floor and the capability check.
19+
- **`@objectstack/plugin-sharing`**: the share-link family (`registerShareLinkRoutes`). Every route, the two capability-token routes included, is now mounted through one door that refuses a context carrying `authGate`. `SharingServicePlugin`'s resolver now puts the session's posture on that context, as REST does.
20+
- **`@objectstack/plugin-webhooks`**: the redeliver door (`WebhookOutboxPlugin`), on both of its faces: the route the plugin mounts, and the `webhooks` service's `handleRedeliver`.
21+
- **`@objectstack/service-storage`**: the storage family, the two capability-token raw-transfer routes included. Every route is now mounted through one door. The posture is bound where every other storage gate is bound, so both the plugin's own mount and the host door `mountStorageRoutes` carry it. When a gate feature is active, the door reads the session and refuses a held one. When the gate is active but the session cannot be read, the door answers the declared `503` (`SERVICE_UNAVAILABLE`), as REST does. A direct call of `registerStorageRoutes` mounts the family exactly as before, because like the other storage gates this one is bound by the package's composition. No exported declaration changes.
22+
23+
- **Refused now:** a held session gets `403` with the gate's own `code` (`PASSWORD_EXPIRED` or `MFA_REQUIRED`) and `message`, in each family's declared error envelope, before the family does anything else. This is the answer REST gives the same session.
24+
- **Unchanged:** a session the policy does not hold is served exactly as before, and an API-key caller is unaffected because it has no session to hold. A CORS preflight is answered as before.
25+
26+
What changes for you. A user the policy holds must remediate (change the password, or enroll an authenticator) before these routes answer them, as on every other protected route. If you mount the storage routes through `mountStorageRoutes`, the gate is bound from the kernel's `auth` service with nothing to configure. If you mount `registerShareLinkRoutes` yourself with your own `contextFromRequest`, set `authGate` on the context it returns for a held session; the door refuses on that field.
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0069 — the share-link family refuses a session the authentication
5+
* policy HOLDS (an expired password, enforced MFA not yet enrolled) with the
6+
* REST seam's answer, on every route — the two token routes included — before
7+
* any of the family's own logic runs.
8+
*
9+
* ## What was open
10+
*
11+
* The family's context resolver read the caller's session and never carried
12+
* its ADR-0069 posture (`user.authGate`, set by plugin-auth's `customSession`
13+
* enrichment), and the routes never asked about one, so a held session listed,
14+
* created and revoked links and resolved tokens as an ordinary signed-in
15+
* caller — measured on a serve-shaped boot before this change, where REST's
16+
* `enforceAuth` and the dispatcher (whose twin of these five routes is gated)
17+
* answer the same session `403` with the gate's `code`.
18+
*
19+
* ## Two halves, both pinned
20+
*
21+
* 1. The DOOR (`registerShareLinkRoutes`): every route is captured off a mock
22+
* `IHttpServer` as the registrar registers it, with a count, and driven
23+
* with a context carrying `authGate` — the declared envelope field the door
24+
* reads, as REST's `enforceAuth` reads it.
25+
* 2. The PRODUCER (`SharingServicePlugin`): the plugin's own resolver, booted,
26+
* puts the session user's posture on that envelope, so a held session
27+
* reaching the mounted routes is refused end to end.
28+
*
29+
* ## What each refusal asserts
30+
*
31+
* The ADR-0112 envelope — `status` AND `error.code` — plus the gate's own
32+
* message, and a service and engine that were never called. The token routes
33+
* also keep their no-store response headers on the refusal.
34+
*
35+
* ## Controls
36+
*
37+
* The same caller un-held is served every route as before, and the family
38+
* registers nothing a CORS preflight could reach.
39+
*/
40+
41+
import { describe, expect, it, vi } from 'vitest';
42+
import { ObjectKernel } from '@objectstack/core';
43+
import type { PluginContext } from '@objectstack/core';
44+
import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts';
45+
import type { ExecutionContext } from '@objectstack/spec/kernel';
46+
import { registerShareLinkRoutes } from './share-link-routes.js';
47+
import { SharingServicePlugin } from './sharing-plugin.js';
48+
import type { ShareLinkService } from './share-link-service.js';
49+
import type { SharingEngine } from './sharing-service.js';
50+
51+
const BASE = '/api/v1/share-links';
52+
53+
/** The gate postures plugin-auth's enrichment writes, one per policy. */
54+
const PASSWORD_EXPIRED = { code: 'PASSWORD_EXPIRED', message: 'Your password has expired. Please change it to continue.' };
55+
const MFA_REQUIRED = {
56+
code: 'MFA_REQUIRED',
57+
message: 'Multi-factor authentication is required. Please set up an authenticator app to continue.',
58+
};
59+
60+
class MockHttp implements IHttpServer {
61+
routes = new Map<string, RouteHandler>();
62+
middleware: unknown[] = [];
63+
private add(method: string, path: string, handler: RouteHandler) {
64+
this.routes.set(`${method} ${path}`, handler);
65+
}
66+
get(path: string, h: RouteHandler) { this.add('GET', path, h); return this as any; }
67+
post(path: string, h: RouteHandler) { this.add('POST', path, h); return this as any; }
68+
put(path: string, h: RouteHandler) { this.add('PUT', path, h); return this as any; }
69+
delete(path: string, h: RouteHandler) { this.add('DELETE', path, h); return this as any; }
70+
patch(path: string, h: RouteHandler) { this.add('PATCH', path, h); return this as any; }
71+
use(...args: unknown[]) { this.middleware.push(args); return this as any; }
72+
listen() { return Promise.resolve(); }
73+
close() { return Promise.resolve(); }
74+
getInstance() { return null; }
75+
}
76+
77+
const LINK_ROW = {
78+
id: 'sl_1',
79+
token: 'tok_abcdefgh',
80+
object_name: 'ai_conversations',
81+
record_id: 'conv_1',
82+
permission: 'view',
83+
audience: 'anyone',
84+
expires_at: null,
85+
label: 'For the auditor',
86+
created_at: '2026-01-01T00:00:00.000Z',
87+
};
88+
89+
/** A service and engine that would SUCCEED on every route, recording every call. */
90+
function mount(contextFromRequest: (req: IHttpRequest) => ExecutionContext | Promise<ExecutionContext>) {
91+
const service = {
92+
createLink: vi.fn(async () => ({ ...LINK_ROW })),
93+
listLinks: vi.fn(async () => [{ ...LINK_ROW }]),
94+
revokeLink: vi.fn(async () => undefined),
95+
resolveToken: vi.fn(async () => ({ link: { ...LINK_ROW }, redactFields: [] })),
96+
};
97+
const engine = {
98+
find: vi.fn(async () => [{ id: 'conv_1', title: 'Shared' }]),
99+
getSchema: vi.fn(() => undefined),
100+
insert: vi.fn(), update: vi.fn(), delete: vi.fn(),
101+
};
102+
const http = new MockHttp();
103+
registerShareLinkRoutes(http, service as unknown as ShareLinkService, engine as unknown as SharingEngine, {
104+
contextFromRequest,
105+
});
106+
const calls = () => [
107+
...Object.entries(service).filter(([, fn]) => fn.mock.calls.length > 0).map(([n]) => `service.${n}`),
108+
...(engine.find.mock.calls.length > 0 ? ['engine.find'] : []),
109+
];
110+
return { http, calls };
111+
}
112+
113+
/** One case per route: the request, and what an un-held signed-in caller gets. */
114+
const CASES: Record<string, { params?: Record<string, string>; body?: unknown; okStatus: number; publicRoute?: boolean }> = {
115+
[`POST ${BASE}`]: { body: { object: 'ai_conversations', recordId: 'conv_1' }, okStatus: 201 },
116+
[`GET ${BASE}`]: { okStatus: 200 },
117+
[`DELETE ${BASE}/:idOrToken`]: { params: { idOrToken: 'sl_1' }, okStatus: 200 },
118+
[`GET ${BASE}/:token/resolve`]: { params: { token: 'tok_abcdefgh' }, okStatus: 200, publicRoute: true },
119+
[`GET ${BASE}/:token/messages`]: { params: { token: 'tok_abcdefgh' }, okStatus: 200, publicRoute: true },
120+
};
121+
122+
async function drive(http: MockHttp, key: string) {
123+
const c = CASES[key]!;
124+
const handler = http.routes.get(key);
125+
if (!handler) throw new Error(`no handler for ${key}`);
126+
const captured: { status: number; body: any; headers: Record<string, string> } = { status: 200, body: undefined, headers: {} };
127+
const res: IHttpResponse = {
128+
json: vi.fn((data: any) => { captured.body = data; }) as any,
129+
send: vi.fn() as any,
130+
status: vi.fn((code: number) => { captured.status = code; return res; }) as any,
131+
header: vi.fn((name: string, value: string) => { captured.headers[name] = value; return res; }) as any,
132+
};
133+
const req = {
134+
params: c.params ?? {},
135+
query: {},
136+
body: c.body,
137+
headers: {},
138+
method: key.slice(0, key.indexOf(' ')),
139+
path: key.slice(key.indexOf(' ') + 1),
140+
} as unknown as IHttpRequest;
141+
await handler(req, res);
142+
return captured;
143+
}
144+
145+
const census = [...mount(() => ({})).http.routes.keys()];
146+
147+
describe('the share-link census — read off the registration', () => {
148+
it('registers exactly five routes, every one has a case below, and no middleware or preflight route', () => {
149+
const { http } = mount(() => ({}));
150+
// ⭐ A count, on purpose: a sixth route has to come through this file.
151+
expect(census).toHaveLength(5);
152+
expect([...census].sort()).toEqual(Object.keys(CASES).sort());
153+
// Nothing here a CORS preflight could reach: no OPTIONS route and no
154+
// middleware, so a preflight is the host's answer, held or not.
155+
expect(census.filter((k) => k.startsWith('OPTIONS '))).toEqual([]);
156+
expect(http.middleware).toEqual([]);
157+
});
158+
});
159+
160+
describe('ADR-0069 door — a held session is refused 403 with the gate code on every route', () => {
161+
const HELD: Array<[string, ExecutionContext]> = [
162+
['held by password expiry', { userId: 'u_1', tenantId: 't_1', authGate: PASSWORD_EXPIRED }],
163+
['held by enforced MFA', { userId: 'u_1', tenantId: 't_1', authGate: MFA_REQUIRED }],
164+
];
165+
for (const [name, ctx] of HELD) {
166+
it.each(census)(`%s · ${name} → 403 with the gate code, nothing reached`, async (key) => {
167+
const { http, calls } = mount(() => ctx);
168+
const answer = await drive(http, key);
169+
expect(answer.status).toBe(403);
170+
expect(answer.body?.success).toBe(false);
171+
expect(answer.body?.error?.code).toBe(ctx.authGate!.code);
172+
expect(answer.body?.error?.message).toBe(ctx.authGate!.message);
173+
expect(answer.body?.data).toBeUndefined();
174+
expect(calls()).toEqual([]);
175+
if (CASES[key]!.publicRoute) expect(answer.headers['Cache-Control']).toBe('no-store');
176+
});
177+
}
178+
});
179+
180+
describe('CONTROL — the same caller un-held is served as before', () => {
181+
it.each(census)('%s · signed in, un-held → its own success status', async (key) => {
182+
const { http, calls } = mount(() => ({ userId: 'u_1', tenantId: 't_1' }));
183+
const answer = await drive(http, key);
184+
expect(answer.status).toBe(CASES[key]!.okStatus);
185+
expect(answer.body?.success).toBe(true);
186+
expect(calls().length).toBeGreaterThan(0);
187+
});
188+
});
189+
190+
// ── The producer half: the plugin's own resolver carries the posture ─────────
191+
192+
interface Session { user: Record<string, unknown>; session: { userId: string } }
193+
194+
async function bootPlugin(session: () => Session | undefined) {
195+
const http = new MockHttp();
196+
const engine = {
197+
async find() { return []; },
198+
async insert(_o: string, row: any) { return row; },
199+
getSchema(object: string) { return { name: object }; },
200+
};
201+
const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false } as any);
202+
const hooks: Record<string, Array<() => Promise<void> | void>> = {};
203+
const ctx = {
204+
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
205+
hook: (event: string, handler: () => Promise<void> | void) => { (hooks[event] ??= []).push(handler); },
206+
getService: <T>(name: string): T => {
207+
if (name === 'objectql') return engine as unknown as T;
208+
if (name === 'http.server') return http as unknown as T;
209+
if (name === 'auth') return { api: { getSession: async () => session() } } as unknown as T;
210+
return kernel.getService<T>(name);
211+
},
212+
registerService: vi.fn(),
213+
getKernel: () => kernel,
214+
};
215+
const plugin = new SharingServicePlugin({ enforce: false });
216+
await plugin.start(ctx as unknown as PluginContext);
217+
for (const handler of hooks['kernel:ready'] ?? []) await handler();
218+
return http;
219+
}
220+
221+
describe('ADR-0069 producer — SharingServicePlugin carries the session posture to the door', () => {
222+
it('a held session reaching the mounted list route is refused 403 PASSWORD_EXPIRED', async () => {
223+
const http = await bootPlugin(() => ({
224+
user: { id: 'u_held', authGate: PASSWORD_EXPIRED },
225+
session: { userId: 'u_held' },
226+
}));
227+
const answer = await drive(http, `GET ${BASE}`);
228+
expect(answer.status).toBe(403);
229+
expect(answer.body?.error?.code).toBe('PASSWORD_EXPIRED');
230+
expect(answer.body?.error?.message).toBe(PASSWORD_EXPIRED.message);
231+
});
232+
233+
it('CONTROL — the same session un-held lists its links', async () => {
234+
const http = await bootPlugin(() => ({ user: { id: 'u_held' }, session: { userId: 'u_held' } }));
235+
const answer = await drive(http, `GET ${BASE}`);
236+
expect(answer.status).toBe(200);
237+
expect(answer.body?.success).toBe(true);
238+
});
239+
});

0 commit comments

Comments
 (0)