diff --git a/.changeset/23225-domain-floor-anonymous-deny.md b/.changeset/23225-domain-floor-anonymous-deny.md new file mode 100644 index 00000000000..970ac65d2a5 --- /dev/null +++ b/.changeset/23225-domain-floor-anonymous-deny.md @@ -0,0 +1,21 @@ +--- +'@objectstack/runtime': minor +--- + +fix(runtime)!: the notification inbox, API-key mint and share-link management dispatcher domains decide an anonymous request once, as each domain's first statement, so an anonymous caller is answered 401 whatever the deployment carries + +ADR-0138 D2 decides that everything off its closed list of six door classes answers 401, and that "the decision is taken once per domain, as the domain's first statement, never surface by surface". Three dispatcher domains decided it per face, below another answer: `/notifications` below its inbox capability probe, `/keys` below its method check, and `/share-links` inside its management faces, below the share-link service probe. Each now opens with the shared `shouldDenyAnonymous` decision, before any service is resolved and before any face is chosen, so a face added to one of them later is refused to an anonymous caller from the day it lands. + +Clause-②: no (narrowing) + + + +**BREAKING** (a refusal moved ahead of the answers it used to follow), shipped as `minor` under the launch-window convention for breaking changes. + +What an unauthenticated caller is answered now, on each domain: + +- `/notifications`: `401 UNAUTHENTICATED` on every face. On a deployment with no inbox (a send-only notification provider, or none) the anonymous answer was the `501` capability answer; it is now the 401, and a signed-in caller there still gets the 501. +- `/keys`: `401 UNAUTHENTICATED` for every verb. A verb other than `POST` used to be answered `405` to an anonymous caller; a signed-in caller still gets the 405. +- `/share-links`: `401 UNAUTHENTICATED` on the create, list and revoke faces and on every path the domain does not serve. On a deployment without the share-link service the anonymous answer was `501`; a signed-in caller there still gets the 501. The two token faces, `GET /share-links/:token/resolve` and `GET /share-links/:token/messages`, are unchanged: the capability token admits them (ADR-0138 D2 class 2). + +The refusal is the dispatcher's standard envelope, `{ success: false, error: { code: 'UNAUTHENTICATED', message, httpStatus: 401 } }`, with the shared message every other dispatcher domain answers with, so a client reads one anonymous refusal on all of them. Nothing a signed-in caller is served changes, and on the standalone server the booted showcase already answered 401 on the faces it mounts. diff --git a/packages/core/src/security/assemble-execution-context.ts b/packages/core/src/security/assemble-execution-context.ts index 6bfd88409bf..583e838b1f1 100644 --- a/packages/core/src/security/assemble-execution-context.ts +++ b/packages/core/src/security/assemble-execution-context.ts @@ -29,6 +29,25 @@ * * ## Two named entries — the anonymous face is genuinely divergent (commit f586f1a89) * + * ⭐ The record of this decision is ADR-0138 D9 + * (`docs/adr/0138-guest-model-anonymous-principal-doors-grants-and-organization.md`), + * which transcribes the ruling below; until it was accepted, the ruling lived + * only in this comment. Read the record before changing either entry. ADR-0138 + * D2 closes the adoption rule this section states: an unauthenticated request + * is served by exactly six door classes — the public form doors, share links, + * the public book and doc metadata reads, `authRequired: false` endpoints of + * `type: 'object_operation'`, `authRequired: false` endpoints of + * `type: 'flow'`, and the `acl: 'public_read'` file download. Only the two + * endpoint classes serve the guest envelope {@link assembleExecutionContextOrGuest} + * builds (the runtime face assembles it for every sessionless request, and + * every other domain refuses it, since it carries no `userId`); the other four + * derive their own narrow authorization from a declaration; everything else + * answers 401, decided once per domain. A surface + * adopting the guest entry outside that list is an amendment of ADR-0138, never + * a call site added here. The list itself is recorded and ratcheted in the + * authorization conformance matrix + * (`packages/qa/dogfood/test/authz-conformance.matrix.ts`). + * * The maintainer ruling of 2026-08-08 (Option A, landed as commit f586f1a89) settled the one * question that blocked convergence: what an anonymous request yields. * diff --git a/packages/qa/dogfood/test/anonymous-door-sweep.dogfood.test.ts b/packages/qa/dogfood/test/anonymous-door-sweep.dogfood.test.ts index a0eedd75035..cdead7e6336 100644 --- a/packages/qa/dogfood/test/anonymous-door-sweep.dogfood.test.ts +++ b/packages/qa/dogfood/test/anonymous-door-sweep.dogfood.test.ts @@ -23,6 +23,20 @@ // refused, because a sweep whose every probe names nothing can only ever // observe the refusal half of a door. // +// ## The matrix rows this file is the proof for +// +// ADR-0138 card E2 records the six classes in the authorization conformance +// matrix (`authz-conformance.matrix.ts`), one row per class, and every one of +// them cites this file: the admitted leg of each class below is that row's +// pin, and its refused leg is the row's standing negative control. +// +// authz-row: anonymous-door-public-form +// authz-row: anonymous-door-share-link +// authz-row: anonymous-door-public-book +// authz-row: anonymous-door-endpoint-object-operation +// authz-row: anonymous-door-endpoint-flow +// authz-row: anonymous-door-public-read-file +// // ## Where the population comes from — three sources, each guarded // // - `port`: `IHttpServer.getMountedRoutes()`, every route registered through diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index fc349611546..7b33f2e0c79 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -10,7 +10,7 @@ // file exists and that the row ↔ proof pairing is MUTUAL (#7976 below), AND // ratchets completeness at ROUTE-FAMILY AND DISPATCHER-DOMAIN granularity over // the two route ledgers, plus a curated table of named gates and transport -// tripwires (`discover()`: 20 probes over 15 named source files) — a new REST +// tripwires (`discover()`: 23 probes over 18 named source files) — a new REST // route FAMILY or dispatcher DOMAIN is UNCLASSIFIED, a deleted named guard is // STALE, and either breaks CI. // @@ -67,10 +67,11 @@ // The ledgers supply the POPULATION; the classification stays a reviewed row // here. // -// Of the 41 ledger keys, 11 are classified by rows below (re-derived when the -// `/webhooks/redeliver` domain arrived classified, #22755, after -// `/approvals/act` did, #22576, and `/i18n` left the baseline, #22432); the -// other 30 are enumerated one by one, +// Of the 41 ledger keys, 15 are classified by rows below (re-derived when the +// `forms` family and the `/notifications`, `/keys` and `/share-links` domains +// arrived classified under ADR-0138 D2, after the `/webhooks/redeliver` domain +// did, #22755, `/approvals/act` did, #22576, and `/i18n` left the baseline, +// #22432); the other 26 are enumerated one by one, // dated, and pinned SHRINK-ONLY in `authz-ledger-population.baseline.ts`, // whose MAX note dates every step down from the 34 of 40 it held on the // 2026-08-31 adoption day. That list can only get shorter: growth, staleness, @@ -100,14 +101,16 @@ // [commit 2ce1eb41b] That completeness is over ROUTES, not over primitives: a primitive // enforced by a predicate inside an existing resolver adds no entry point, so // it can be neither UNCLASSIFIED nor STALE. Measured against the rows below: -// 44 of 56 carry no `covers` key at all (12 rows, 22 keys, every one an -// HTTP/transport pin), and 38 of the file's 49 `enforced` rows are exactly -// that in-resolver shape — the ADR-0049/#8613 `active` rows and the ADR-0091 +// 49 of 65 carry no `covers` key at all (16 rows, 29 keys, every one an +// HTTP/transport pin), and 43 of the file's 58 `enforced` rows carry none: +// the in-resolver shape — the ADR-0049/#8613 `active` rows and the ADR-0091 // grant-validity-window row among them (see their own blocks further down) -// are the normal case, not an exception. Of the -// 22 `covers` keys that DO exist, 7 are GATE pins tied to the enforcement call +// are the normal case, not an exception — plus five ADR-0138 D2 door-class +// rows whose admission their proof drives on a booted deployment. Of the +// 29 `covers` keys that DO exist, 10 are GATE pins tied to the enforcement call // itself, not merely a function name — delete `shouldDenyAnonymous` from -// `/actions`, `/automation`, `/packages`, `/analytics` or `/i18n`, or drop the MCP +// `/actions`, `/automation`, `/packages`, `/analytics`, `/i18n`, +// `/notifications`, `/keys` or `/share-links`, or drop the MCP // context-threading / stdio principal binding, and the pinned key vanishes from source, its row // goes STALE, and CI catches the regression. That anti-regression property is // real and is what this file mechanically delivers. Outside the curated @@ -310,6 +313,24 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ // pairing the `/packages` and `/analytics` rows make. covers: ['i18n:domains/i18n.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/i18n'], note: 'Ungated, an unauthenticated caller was served the application\'s translation bundle (the labels of its objects, fields, apps and pages) while the metadata read of the same objects answered it 401 on the same boot; ADR-0056 D2 is default-deny and no ADR-0138 D2 door class names this domain. Gating the DOMAIN rather than each face keeps a newly added face from arriving ungated. The Console renders its sign-in page from its built-in packs and reads this domain once signed in, so the console pin moved past that change in the same landing. The cited proof drives every mounted face anonymously (401, the dispatcher-wrapper envelope, nothing of the bundle served) and with a signed-in member (200, served) on one boot. The per-face unit pins (anonymous 401 with the provider never consulted, a missing locale and an empty slot still 401, signed-in control unchanged) live in runtime/domains/i18n-anonymous-deny.test.ts.' }, + // ── ADR-0138 D2 (card E2) — "the decision is taken once per domain, as the + // domain's first statement, never surface by surface". These three domains + // decided it per face — below a capability probe or a method check — until + // the decision moved to their entry, the `/analytics` shape above: one + // handler body, one domain-wide floor, a gate pin that goes STALE when the + // floor is deleted, and the domain's ledger key classified beside it. + { id: 'anonymous-deny-notifications', summary: 'anonymous-deny on the in-app notification inbox surface', state: 'enforced', + enforcement: 'runtime/domains/notifications.ts handleNotificationRequest — shouldDenyAnonymous DOMAIN-WIDE as the handler\'s FIRST statement, ahead of the inbox capability probe (so a 401-vs-501 difference cannot fingerprint whether the deployment carries an inbox) and ahead of every face; every face, and every path the domain does not serve, answers the dispatcher-wrapper 401 UNAUTHENTICATED, and a signed-in caller is served as before', + covers: ['notifications:domains/notifications.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/notifications'], + note: 'Before the move the decision was the domain\'s own user-id check, written below the capability probe, so a deployment without an inbox answered an anonymous caller 501 while one with an inbox answered 401 — the booted showcase carries an inbox, which is why the sweep read 401 there. Gating the DOMAIN rather than each face keeps a newly added face from arriving ungated. Unit-proven in runtime/domains/domain-floor-anonymous-deny.test.ts (every face and an unserved path refused 401 before any service is resolved, both anonymous shapes the dispatcher produces, an empty slot still 401, the signed-in control unchanged); on the booted showcase the mounted faces are held to 401 by the anonymous door sweep\'s everything-else class.' }, + { id: 'anonymous-deny-keys', summary: 'anonymous-deny on the API-key mint surface', state: 'enforced', + enforcement: 'runtime/domains/keys.ts handleKeysRequest — shouldDenyAnonymous DOMAIN-WIDE as the handler\'s FIRST statement, ahead of the method check (so an anonymous caller is never answered the 405 describing the route\'s shape) and ahead of the body whitelist and the data-service probe; every verb answers the dispatcher-wrapper 401 UNAUTHENTICATED, and a signed-in caller reaches the mint exactly as before', + covers: ['keys:domains/keys.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/keys'], + note: 'Before the move the decision was the domain\'s own user-id check, written below the method check, so an anonymous caller using any verb but POST was answered 405. The key is pinned to the caller and never read from the body; that rule is unchanged and runs after this floor. Unit-proven in runtime/domains/domain-floor-anonymous-deny.test.ts (the mint face and every unserved verb refused 401 before any service is resolved; a member still gets the 405 for a wrong verb and reaches the data service on POST); on the booted showcase the mounted face is held to 401 by the anonymous door sweep\'s everything-else class.' }, + { id: 'anonymous-deny-share-links', summary: 'anonymous-deny on the share-link management surface of the dispatcher (the two token faces are ADR-0138 D2 class 2)', state: 'enforced', + enforcement: 'runtime/domains/share-links.ts handleShareLinksRequest — shouldDenyAnonymous DOMAIN-WIDE as the domain\'s first decision, ahead of the share-link service probe and every face, with the ONE exception ADR-0138 D2 names for this domain: the two token faces (GET /:token/resolve, GET /:token/messages), selected by the same isPublicShareLinkRoute predicate that marks them public, which the capability token admits (class 2). Every other face, and every path or verb the domain does not serve, answers the dispatcher-wrapper 401 UNAUTHENTICATED', + covers: ['share-links:domains/share-links.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/share-links'], + note: 'The DISPATCHER domain only: the dispatcher twin of the plugin-sharing mount, the surface a per-environment kernel serves share links from, and unobservable on the standalone showcase boot, where plugin-sharing mounts its own routes. Before the move the decision was the management faces\' own user-id check, written inside the body below the service probe, so a deployment without the share-link service answered an anonymous caller 501. A new public face here is an amendment of ADR-0138, never an edit to the predicate alone. Unit-proven in runtime/domains/domain-floor-anonymous-deny.test.ts (each management face and every unserved path or verb refused 401 before any service is resolved, both anonymous shapes; the token faces still admitted and judged by the token; the signed-in control unchanged).' }, // #22576 — the `/approvals/act` dispatcher domain (segment 2 of ruling A on // #22438, "token-only, aligned with self-hosted"). Unlike every @@ -352,6 +373,55 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ covers: ['dispatcher-domain:route-ledger.ts:/webhooks/redeliver'], note: 'The DISPATCHER domain only: on a self-hosted kernel the same path is served from the raw Hono app and never reaches the dispatcher, which is why the route-ledger row is pinned unobservable on the showcase boot. Unit- and wire-proven, not dogfood-proven: plugin-webhooks webhook-redeliver-member.test.ts drives the member on a real kernel, messaging service and outbox (every refusal the contract declares, the tenant scope, the veto, byte parity with the mount); runtime domains/webhooks-redeliver.test.ts pins the domain on the real HttpDispatcher (the request forwarded unread, POST alone claimed, 501 for an absent slot or member, the slot read per request, a signed-in non-member and a gated session stopped before the member while a member of the environment and an anonymous caller reach it); http-conformance hono-webhooks-redeliver.conformance.test.ts drives the real plugin through createHonoApp and the self-hosted mount, where a replay lands in the outbox and every refusal, anonymous first, answers the same bytes at both doors.' }, + // ── ADR-0138 D2 — THE CLOSED LIST OF DOORS (card E2) ───────────────────── + // + // "An unauthenticated request is served by exactly these six door classes"; + // everything else answers 401, decided per domain (the `anonymous-deny-*` + // rows above). Each row below is one class, and names its enforcing site as + // D2's table does. Naming a seventh class is an amendment of ADR-0138, never + // a row appended here alone. + // + // ONE proof claims all six: `anonymous-door-sweep.dogfood.test.ts`, the + // in-tree evidence of the record's criterion 2. It boots the showcase, sends + // one unauthenticated request per registered route, and judges each by its + // closed classification (`anonymous-door-sweep.classification.ts`); and it + // asserts each class on a REAL subject, ADMITTED and REFUSED — the refused + // leg is each class's standing negative control (an undeclared form, a + // revoked token, an `org`-audience book, a session-gated endpoint of the same + // type, a private file). Remove a class's admission and its admitted leg + // turns red; a class whose door stopped admitting cannot stay recorded here. + // + // ⚠️ What the sweep's legs cannot reach is stated where it applies: class 1 + // is the only class that owns a whole route family (both `forms` routes are + // its doors), so it is the only one of the six that classifies a ledger key; + // the other five ride families and domains whose other faces are the + // everything-else class. + { id: 'anonymous-door-public-form', summary: 'ADR-0138 D2 class 1 — the public form doors (resolve and submit), admitted by the form\'s own sharing declaration: enabled, anonymous, a public link naming the slug', state: 'enforced', + enforcement: 'rest/rest-server.ts RestServer.registerFormEndpoints — which forms are open is the one anonymous-intake rule shared with publish (the form\'s sharing declaration, a withdrawal in any layer closing it), and an open form runs as the form grant; plugin-security/security-plugin.ts SecurityPlugin, the publicFormGrant branch — insert and read-back on that one object, nothing else, with the server-managed anchors stripped', + proof: 'anonymous-door-sweep.dogfood.test.ts', + covers: ['rest-family:rest-route-ledger.ts:forms'], + note: 'The REST `forms` family is exactly these two routes and both are the class, which is the property a family key needs. The proof resolves and submits a form that declares anonymous sharing (200; the submit answers the created id and nothing it stored) and refuses a slug no form declares. The grant\'s strip is its own row (`public-form-managed-anchors`).' }, + { id: 'anonymous-door-share-link', summary: 'ADR-0138 D2 class 2 — share links, admitted by the capability token; after validation, a read of that one record', state: 'enforced', + enforcement: 'plugin-sharing/share-link-routes.ts registerShareLinkRoutes — the two token faces validate the token (revoked, expired, unknown, password, audience and the object\'s standing publicSharing switch) and then read the one record it names; the dispatcher twin is runtime/domains/share-links.ts, whose every other face stands on the domain floor (`anonymous-deny-share-links`)', + proof: 'anonymous-door-sweep.dogfood.test.ts', + note: 'The proof mints a link signed in, resolves it anonymously to the one record it names, revokes it and is refused. No ledger key: the plugin mount is in neither of the two route ledgers this matrix reads, and the dispatcher twin\'s domain key is classified by its floor row, because that domain\'s other faces are everything-else.' }, + { id: 'anonymous-door-public-book', summary: 'ADR-0138 D2 class 3 — the public book and doc metadata reads, admitted by `book.audience: \'public\'`; reachability only, the ADR-0046 §6.7 audience gate authorizes the read', state: 'enforced', + enforcement: 'rest/meta-item-read-gate.ts isPublicAudienceRead — the one exception the metadata floor makes: an anonymous GET of the book or doc list or item read skips the deny and the §6.7 audience gate inside that read decides (`public` only), on the REST registrar and on the dispatcher metadata domain alike; every other type, verb and route shape keeps the floor (`anonymous-deny-meta`)', + proof: 'anonymous-door-sweep.dogfood.test.ts', + note: 'No route of its own: the class rides the shared metadata read routes, so its ledger keys are the metadata floor\'s. The proof reads the public book, its tree and a doc it claims anonymously, and is refused 401 on an org-audience book, a doc only that book claims, and a write to the public book.' }, + { id: 'anonymous-door-endpoint-object-operation', summary: 'ADR-0138 D2 class 4 — `authRequired: false` endpoints of `type: \'object_operation\'`, admitted by the endpoint declaration with its armed rate limit; the request runs as the guest envelope under the guest\'s grants', state: 'enforced', + enforcement: 'runtime/security/resolve-execution-context.ts resolveExecutionContext — the runtime face takes the explicit guest entry (ADR-0138 D9: principalKind guest, positions [guest], never isSystem, no user), so the CRUD gate judges the guest; with nothing bound to the guest, its empty grant set denies (ADR-0138 D3)', + proof: 'anonymous-door-sweep.dogfood.test.ts', + note: 'Declared endpoints are served from the transport\'s fallback seam and sit in no route table, so the class has no ledger key; the proof reads them from the metadata service. It asserts the engine is asked once, with the guest envelope, and the answer is D3\'s empty-state refusal, and that the showcase\'s own session-gated endpoint of the same type answers 401.' }, + { id: 'anonymous-door-endpoint-flow', summary: 'ADR-0138 D2 class 5 — `authRequired: false` endpoints of `type: \'flow\'`, admitted by the endpoint declaration with its armed rate limit; the guest envelope at the door, and inside the flow its own declared runAs', state: 'enforced', + enforcement: 'runtime/security/resolve-execution-context.ts resolveExecutionContext (the guest envelope at the door), then @objectstack/service-automation, which runs the flow under its own declared runAs', + proof: 'anonymous-door-sweep.dogfood.test.ts', + note: 'No ledger key, for the reason class 4 states. The proof asserts the flow runs once, started with the guest envelope (positions [guest], no user, not system), and serves its output and nothing else, and that the showcase\'s own session-gated flow endpoint answers 401. The publish refusal of an anonymous flow door whose target runs as system is ADR-0138 D2b, a separate slice of the same card.' }, + { id: 'anonymous-door-public-read-file', summary: 'ADR-0138 D2 class 6 — the `acl: \'public_read\'` file download (ADR-0104 D3 wave 2), admitted by the file\'s own explicit opt-in; a read of that one file\'s bytes, no principal evaluated', state: 'enforced', + enforcement: 'service-storage/storage-routes.ts registerStorageRoutes, at the download gate authorizeDownload that both download routes call — a file whose acl is public_read is served without a principal; the upload routes create every file private, so the door opens only by that explicit opt-in', + proof: 'anonymous-door-sweep.dogfood.test.ts', + note: 'The storage routes are service-storage\'s own mount, recorded in its own per-package ledger and in neither of the two route ledgers this matrix reads, so the class has no ledger key here. The proof uploads two files signed in, opts one into public_read, serves its own bytes anonymously at both download doors, and is refused 401 at both doors on the private one.' }, + // ── #2992 / ADR-0096 D4 — latent execution surfaces (pre-wiring identity // admission). Neither surface is reachable by a client today; these rows // register their identity posture NOW so the ratchet (see the probes + diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index b79b65dfeb7..883dff3202a 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -260,6 +260,29 @@ const PROBES: readonly Probe[] = [ re: /shouldDenyAnonymous\s*\(/g, key: () => 'i18n:domains/i18n.ts:anonymous-gate', }, + // ADR-0138 D2 (card E2) — the three domains whose anonymous decision moved + // from a face to the domain's first statement. Same GATE-pin shape: the key + // exists only while the handler still consults `shouldDenyAnonymous`. Delete + // a domain floor and its key vanishes → the covering `anonymous-deny-*` row + // goes STALE → red CI. + { + kind: 'GATE_PIN', + file: 'packages/runtime/src/domains/notifications.ts', + re: /shouldDenyAnonymous\s*\(/g, + key: () => 'notifications:domains/notifications.ts:anonymous-gate', + }, + { + kind: 'GATE_PIN', + file: 'packages/runtime/src/domains/keys.ts', + re: /shouldDenyAnonymous\s*\(/g, + key: () => 'keys:domains/keys.ts:anonymous-gate', + }, + { + kind: 'GATE_PIN', + file: 'packages/runtime/src/domains/share-links.ts', + re: /shouldDenyAnonymous\s*\(/g, + key: () => 'share-links:domains/share-links.ts:anonymous-gate', + }, // ── a probe whose POPULATION WAS DELETED, re-declared for what it is ──── // @@ -781,6 +804,25 @@ function checkAuthzLedger( return [...checkLedger(rows, opts), ...checkTransportWiredAdmission(rows)]; } +/** + * ADR-0138 D2's six door classes, in the record's order, as the matrix rows + * that record them (card E2). The list is CLOSED: "an unauthenticated request + * is served by exactly these six door classes", and naming a seventh is an + * amendment of the record — so a row added beside these without one, or one of + * these removed, is red below. + */ +const ADR_0138_D2_DOOR_CLASS_ROWS = [ + 'anonymous-door-public-form', + 'anonymous-door-share-link', + 'anonymous-door-public-book', + 'anonymous-door-endpoint-object-operation', + 'anonymous-door-endpoint-flow', + 'anonymous-door-public-read-file', +] as const; + +/** The proof every door-class row cites: the record's criterion-2 sweep. */ +const ADR_0138_D2_DOOR_PROOF = 'anonymous-door-sweep.dogfood.test.ts'; + const HIGH_RISK = [ 'owd-private', 'owd-public-read', 'controlled-by-parent', 'anonymous-deny', 'default-profile', // #2567 — every anonymous-deny HTTP surface is high-risk: it guards the @@ -809,6 +851,10 @@ const HIGH_RISK = [ // through a sibling execution surface (tool dispatch); proven e2e that a // member's MCP tools/call is RLS-scoped and anonymous is denied. 'mcp-http-identity', + // ADR-0138 D2 — the six door classes. Each SERVES object data, or a file's + // bytes, to a caller with no session, so each owes the end-to-end proof + // that it admits exactly its declared subject and refuses the rest. + ...ADR_0138_D2_DOOR_CLASS_ROWS, ]; describe('ADR-0056 D10 — authorization conformance matrix', () => { @@ -987,11 +1033,19 @@ describe('the ledger-sourced population and its baseline bite', () => { 'dispatcher-domain:route-ledger.ts:/automation', // [#22432] classified by `anonymous-deny-i18n`. 'dispatcher-domain:route-ledger.ts:/i18n', + // [ADR-0138 D2] classified by `anonymous-deny-keys`. + 'dispatcher-domain:route-ledger.ts:/keys', 'dispatcher-domain:route-ledger.ts:/mcp', 'dispatcher-domain:route-ledger.ts:/meta', + // [ADR-0138 D2] classified by `anonymous-deny-notifications`. + 'dispatcher-domain:route-ledger.ts:/notifications', 'dispatcher-domain:route-ledger.ts:/packages', + // [ADR-0138 D2] classified by `anonymous-deny-share-links`. + 'dispatcher-domain:route-ledger.ts:/share-links', // [#22755] classified by `webhook-redeliver-session`. 'dispatcher-domain:route-ledger.ts:/webhooks/redeliver', + // [ADR-0138 D2] classified by `anonymous-door-public-form` (class 1). + 'rest-family:rest-route-ledger.ts:forms', 'rest-family:rest-route-ledger.ts:metadata', // [#22430] classified by `anonymous-deny-api-description`. 'rest-family:rest-route-ledger.ts:openapi', @@ -1183,6 +1237,165 @@ describe('#2567 — anonymous-deny surface ratchet bites', () => { }); }); +// ── ADR-0138 D2 (card E2) — the closed list of doors, and denial by domain ── +// +// The record: "An unauthenticated request is served by exactly these six door +// classes … Everything else answers 401. The decision is taken once per +// domain, as the domain's first statement … A new surface is therefore 401 +// until this list names it." Enforced by "the authorization conformance +// matrix, whose rows already fail on a deleted deny call or an unclassified +// route. Card E2 records the six classes there." +// +// What this block holds, and at which granularity: +// +// - THE LIST. Exactly six door-class rows, each `enforced`, each naming its +// D2 enforcing site, each proven end to end by the sweep, which claims +// exactly those six. A seventh row, a missing one, or a row that stops +// citing its proof is red. +// - A NEW ROUTE IN A GOVERNED DOMAIN ANSWERS 401, by the two mechanisms the +// matrix has. A new route FAMILY or dispatcher DOMAIN mints a ledger key and +// is UNCLASSIFIED until a row covers it (pinned in the population block +// above, for both ledgers). A route added INSIDE an existing domain mints no +// key (the matrix header's own measured bound) and is held instead by the +// domain's floor being its first statement: deleting that floor makes the +// domain's gate pin STALE (pinned here, for every gate-pinned domain), and +// each moved domain's unit suite asserts that a path no face serves yet is +// refused 401 before route selection +// (`runtime/src/domains/domain-floor-anonymous-deny.test.ts`). On a booted +// deployment, the sweep's everything-else class turns red for any route +// that serves an anonymous caller without a door class. + +/** + * D2's enforcing site for each class, by function name, as the record's table + * names it. A row whose enforcement text stops naming these has stopped + * describing the door D2 lists. + */ +const ADR_0138_D2_ENFORCING_SITES: Readonly> = { + 'anonymous-door-public-form': ['RestServer.registerFormEndpoints', 'publicFormGrant'], + 'anonymous-door-share-link': ['registerShareLinkRoutes'], + 'anonymous-door-public-book': ['isPublicAudienceRead'], + 'anonymous-door-endpoint-object-operation': ['resolveExecutionContext', 'CRUD gate'], + 'anonymous-door-endpoint-flow': ['resolveExecutionContext', '@objectstack/service-automation'], + 'anonymous-door-public-read-file': ['registerStorageRoutes', 'authorizeDownload'], +}; + +/** The gate-pinned dispatcher domains: deleting any one floor goes STALE. */ +const DOMAIN_FLOOR_GATE_KEYS = [ + 'actions:domains/actions.ts:anonymous-gate', + 'automation:domains/automation.ts:anonymous-gate', + 'packages:domains/packages.ts:anonymous-gate', + 'analytics:domains/analytics.ts:anonymous-gate', + 'i18n:domains/i18n.ts:anonymous-gate', + 'notifications:domains/notifications.ts:anonymous-gate', + 'keys:domains/keys.ts:anonymous-gate', + 'share-links:domains/share-links.ts:anonymous-gate', +] as const; + +/** Every row recording a door class carries this id prefix, and no other row may. */ +const DOOR_CLASS_ROW_PREFIX = 'anonymous-door-'; + +function checkDoorClassList(rows: readonly AuthzPrimitive[], highRisk: readonly string[]): string[] { + const problems: string[] = []; + const listed: readonly string[] = ADR_0138_D2_DOOR_CLASS_ROWS; + for (const r of rows) { + if (r.id.startsWith(DOOR_CLASS_ROW_PREFIX) && !listed.includes(r.id)) { + problems.push( + `${r.id}: a door-class row ADR-0138 D2 does not list. The list is closed; naming a new door class is an ` + + 'amendment of ADR-0138, and the row follows that amendment, never the reverse.', + ); + } + } + for (const id of ADR_0138_D2_DOOR_CLASS_ROWS) { + const row = rows.find((r) => r.id === id); + if (!row) { + problems.push(`${id}: ADR-0138 D2 lists this door class and no matrix row records it.`); + continue; + } + if (row.state !== 'enforced') problems.push(`${id}: a door class is recorded \`enforced\`, not '${row.state}'.`); + if (row.proof !== ADR_0138_D2_DOOR_PROOF) { + problems.push( + `${id}: cites ${row.proof ? `\`${row.proof}\`` : 'no proof'}; a door class is proven by ${ADR_0138_D2_DOOR_PROOF}.`, + ); + } + if (!highRisk.includes(id)) problems.push(`${id}: a door class serves data with no session and must be HIGH_RISK.`); + for (const site of ADR_0138_D2_ENFORCING_SITES[id]) { + if (!(row.enforcement ?? '').includes(site)) { + problems.push(`${id}: the enforcement site no longer names \`${site}\`, which ADR-0138 D2's table names for this class.`); + } + } + } + return problems; +} + +describe('ADR-0138 D2 — the closed list of doors is recorded, and denial is by domain', () => { + const clone = (): AuthzPrimitive[] => JSON.parse(JSON.stringify(AUTHZ_CONFORMANCE)); + const opts = (discover: () => Iterable) => ({ + proofRoot: HERE, + highRisk: HIGH_RISK, + discover, + attribution: ATTRIBUTION, + }); + + it('records exactly the six door classes, each enforced, named as D2 names it, high-risk and proven by the sweep', () => { + expect(ADR_0138_D2_DOOR_CLASS_ROWS).toHaveLength(6); + expect(checkDoorClassList(AUTHZ_CONFORMANCE, HIGH_RISK)).toEqual([]); + }); + + it('the sweep claims exactly the six door-class rows, and nothing else', () => { + // Mutuality is `checkLedger`'s; this pins the SET, so the proof cannot + // quietly start or stop speaking for a class. + const sweep = readFileSync(join(HERE, ADR_0138_D2_DOOR_PROOF), 'utf8'); + const claims = [...sweep.matchAll(/^\/\/ authz-row: (\S+)$/gm)].map((m) => m[1]).sort(); + expect(claims).toEqual([...ADR_0138_D2_DOOR_CLASS_ROWS].sort()); + }); + + it('NEGATIVE CONTROL — a seventh door-class row is red, and so is a missing one', () => { + const seventh = clone(); + seventh.push({ + id: `${DOOR_CLASS_ROW_PREFIX}unlisted`, summary: 'a door ADR-0138 D2 does not list', state: 'enforced', + enforcement: 'somewhere', proof: ADR_0138_D2_DOOR_PROOF, + }); + expect(checkDoorClassList(seventh, HIGH_RISK).some((p) => p.includes('does not list'))).toBe(true); + + const missing = clone().filter((r) => r.id !== 'anonymous-door-public-read-file'); + expect(checkDoorClassList(missing, HIGH_RISK).some((p) => p.includes('no matrix row records it'))).toBe(true); + // …and the ledger itself reds on the same edit: the sweep's claim is orphaned. + expect( + checkAuthzLedger(missing, opts(() => discoverAnonymousDenySurfaces())).some((p) => /orphaned claim/.test(p)), + ).toBe(true); + }); + + it('NEGATIVE CONTROL — a door class that stops citing its proof, or stops naming its site, is red', () => { + const unproven = clone(); + unproven.find((r) => r.id === 'anonymous-door-endpoint-flow')!.proof = undefined; + expect(checkDoorClassList(unproven, HIGH_RISK).some((p) => p.includes('cites no proof'))).toBe(true); + const ledger = checkAuthzLedger(unproven, opts(() => discoverAnonymousDenySurfaces())); + expect(ledger.some((p) => p.includes('high-risk anonymous-door-endpoint-flow must carry a proof'))).toBe(true); + expect(ledger.some((p) => /attribution is not mutual/.test(p))).toBe(true); + + const reworded = clone(); + reworded.find((r) => r.id === 'anonymous-door-public-book')!.enforcement = 'a reworded site'; + expect(checkDoorClassList(reworded, HIGH_RISK).some((p) => p.includes('isPublicAudienceRead'))).toBe(true); + }); + + it('NEGATIVE CONTROL — deleting ANY gate-pinned domain floor goes STALE (a route inside it would arrive ungated)', () => { + for (const gate of DOMAIN_FLOOR_GATE_KEYS) { + // Baseline sanity: each floor is in source TODAY. + expect(discoverAnonymousDenySurfaces().has(gate), `${gate} must be in source`).toBe(true); + const problems = checkAuthzLedger( + AUTHZ_CONFORMANCE, + opts(() => new Set([...discoverAnonymousDenySurfaces()].filter((k) => k !== gate))), + ); + expect(problems.some((p) => /STALE covers/.test(p) && p.includes(gate)), gate).toBe(true); + } + }); + + it('every domain-floor gate pin the table mints is one of the pinned floors above — none is left out', () => { + const minted = [...mintAllSurfaceKeys()].filter((k) => k.endsWith(':anonymous-gate')).sort(); + expect(minted).toEqual([...DOMAIN_FLOOR_GATE_KEYS].sort()); + }); +}); + // ── #9083 — the TRANSPORT-WIRED admission rule bites ────────────────────── // // This block is the filer's two-leg reverse verification, mechanised: leg 1 diff --git a/packages/qa/dogfood/test/authz-ledger-population.baseline.ts b/packages/qa/dogfood/test/authz-ledger-population.baseline.ts index aa731b654de..1bcf93b5843 100644 --- a/packages/qa/dogfood/test/authz-ledger-population.baseline.ts +++ b/packages/qa/dogfood/test/authz-ledger-population.baseline.ts @@ -71,8 +71,9 @@ export const LEDGER_POPULATION_BASELINE: readonly string[] = [ // ── REST families (`packages/rest/src/rest-route-ledger.ts`) ──────────── // `metadata` is absent because it IS classified — `anonymous-deny-meta` // covers it through the guarded registrar — and so is `openapi`, classified - // by `anonymous-deny-api-description` since #22430. The other 16 families - // are here. + // by `anonymous-deny-api-description` since #22430, and `forms`, classified + // by `anonymous-door-public-form` (ADR-0138 D2 class 1: both of its routes + // are the class). The other 15 families are here. 'rest-family:rest-route-ledger.ts:analytics', 'rest-family:rest-route-ledger.ts:approvals', 'rest-family:rest-route-ledger.ts:batch', @@ -81,7 +82,6 @@ export const LEDGER_POPULATION_BASELINE: readonly string[] = [ 'rest-family:rest-route-ledger.ts:discovery', 'rest-family:rest-route-ledger.ts:email', 'rest-family:rest-route-ledger.ts:external-datasource', - 'rest-family:rest-route-ledger.ts:forms', // ⚠️ NOT the same surface as `dispatcher-domain:route-ledger.ts:/packages`, // which IS classified: that key names the dispatcher domain whose single // handler body carries the domain-wide gate. This one names the four routes @@ -96,7 +96,10 @@ export const LEDGER_POPULATION_BASELINE: readonly string[] = [ // ── dispatcher domains (`packages/runtime/src/route-ledger.ts`) ───────── // Absent because classified: `/meta`, `/actions`, `/automation`, - // `/packages`, `/mcp`, `/analytics`, `/i18n` (#22432). The other 14 domains + // `/packages`, `/mcp`, `/analytics`, `/i18n` (#22432), and `/notifications`, + // `/keys` and `/share-links` (ADR-0138 D2: each domain's anonymous decision + // moved to its first statement). `/approvals/act` and `/webhooks/redeliver` + // were never enumerated here: they arrived classified. The other 11 domains // are here. 'dispatcher-domain:route-ledger.ts:/.well-known/objectstack', 'dispatcher-domain:route-ledger.ts:/ai', @@ -105,16 +108,13 @@ export const LEDGER_POPULATION_BASELINE: readonly string[] = [ 'dispatcher-domain:route-ledger.ts:/data', 'dispatcher-domain:route-ledger.ts:/discovery', 'dispatcher-domain:route-ledger.ts:/health', - 'dispatcher-domain:route-ledger.ts:/keys', // ⚠️ Separate from `/mcp`, deliberately. The `/mcp` key is classified by // `mcp-http-identity`, whose enforcement site is `handleMcp`; `/mcp/skill` // is a different handler body (`handleMcpSkillRequest`) that the probe // table was already measured not to reach. 'dispatcher-domain:route-ledger.ts:/mcp/skill', - 'dispatcher-domain:route-ledger.ts:/notifications', 'dispatcher-domain:route-ledger.ts:/ready', 'dispatcher-domain:route-ledger.ts:/security', - 'dispatcher-domain:route-ledger.ts:/share-links', 'dispatcher-domain:route-ledger.ts:/ui', ]; @@ -131,6 +131,12 @@ export const LEDGER_POPULATION_BASELINE: readonly string[] = [ * (#22430): `rest-family:rest-route-ledger.ts:openapi` left classified, by the * `anonymous-deny-api-description` row and its booted-showcase proof. 30 at * 2026-10-09 (#22432): `dispatcher-domain:route-ledger.ts:/i18n` left - * classified, by the `anonymous-deny-i18n` row and its domain gate pin. + * classified, by the `anonymous-deny-i18n` row and its domain gate pin. 26 at + * 2026-10-13 (ADR-0138 card E2): `rest-family:rest-route-ledger.ts:forms` left + * classified by `anonymous-door-public-form`, the D2 class 1 row and its + * booted proof, and `dispatcher-domain:route-ledger.ts:/notifications`, + * `/keys` and `/share-links` left classified by `anonymous-deny-notifications`, + * `anonymous-deny-keys` and `anonymous-deny-share-links`, each with its domain + * gate pin, once each domain's anonymous decision moved to its first statement. */ -export const LEDGER_POPULATION_BASELINE_MAX = 30; +export const LEDGER_POPULATION_BASELINE_MAX = 26; diff --git a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts index 3d6aff05856..67a01867d3c 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -302,7 +302,13 @@ export interface ProbeTableReading { // `webhook-redeliver-session` row covers one more key, the // `/webhooks/redeliver` dispatcher-domain key, which arrived classified rather // than through the baseline (the baseline still holds 30, its ceiling unmoved). -export const PROBE_TABLE: ProbeTableReading = { entries: 20, files: 15, keys: 22 }; +// [ADR-0138 E2] 20 / 15 / 22 -> 23 / 18 / 29: the `/notifications`, `/keys` and +// `/share-links` domain gates joined the table as GATE_PINs on their three +// domain files, once each domain's anonymous decision moved to its first +// statement; the three `anonymous-deny-*` rows cover each gate key plus its +// dispatcher-domain key, and `anonymous-door-public-form` covers the `forms` +// REST family key — four keys that left the shrink-only baseline (30 -> 26). +export const PROBE_TABLE: ProbeTableReading = { entries: 23, files: 18, keys: 29 }; /** * The probe count `authz-conformance.matrix.ts`'s header states. @@ -325,8 +331,10 @@ export const PROBE_TABLE: ProbeTableReading = { entries: 20, files: 15, keys: 22 * probe it counts, which is exactly what the inverted pin asks for. * [#22432] 19 -> 20, the same way: the `/i18n` gate pin and the header * sentence moved together. + * [ADR-0138 E2] 20 -> 23, the same way: the three domain gate pins and the + * header sentence moved together. */ -export const MATRIX_HEADER_PROBE_CLAIM = 20; +export const MATRIX_HEADER_PROBE_CLAIM = 23; export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // ── the two LEDGER files: the population source since 2026-08-31 ─────── @@ -365,13 +373,15 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ controls: { "route: '": 81, "family: '": 81, RestRouteLedgerEntry: 2 }, // [#22430] The `openapi` family is the second classified one, by the // `anonymous-deny-api-description` row; the note's 2 / 16 counts it. + // [ADR-0138 E2] The `forms` family is the third, by the D2 class 1 row + // `anonymous-door-public-form` (both of its routes are the class): 3 / 15. note: 'The audited disposition of every route @objectstack/rest mounts, enumerated through ' + 'RestServer.getRoutes() on a booted server and guarded per route by rest-route-ledger.conformance.test.ts. ' + 'That guard is why this file can be a population source and a regex table cannot: a mounted route with no ' + 'row here is already RED in another package, so a new family cannot be silently absent from this file, ' + - 'and therefore cannot be silently absent from the authz ratchet either. 18 families; 2 classified by ' + - 'matrix rows (metadata and openapi), 16 enumerated in the shrink-only baseline. Re-measured 82 -> 81 when the permission-set ' + + 'and therefore cannot be silently absent from the authz ratchet either. 18 families; 3 classified by ' + + 'matrix rows (metadata, openapi and forms), 15 enumerated in the shrink-only baseline. Re-measured 82 -> 81 when the permission-set ' + 'overlay-discard row left with its route; it carried `family: security`, which the surviving security rows still carry. ' + 'Earlier re-measured 83 -> 82 when the anonymous ' + 'public-form lookup-picker row left with its route; it carried `family: forms`, which the surviving form rows ' + @@ -464,15 +474,19 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // [#22755] `domains/webhooks.ts` is a seventeenth domain file with an // eighteenth plain prefix, named by no probe, and its key is the ninth // classified: 23 domains, 14 still in the baseline. + // [ADR-0138 E2] `domains/notifications.ts`, `domains/keys.ts` and + // `domains/share-links.ts` are now named by a probe (their domain floors' + // gate pins), and their three keys are the tenth to twelfth classified: + // 23 domains, 11 still in the baseline, 8 domain files no probe names. note: 'The dispatcher half. Its machine contract is DOMAIN-level by live registry introspection ' + '(domainRegistry.list()), guarded in BOTH directions by route-ledger.conformance.test.ts: every ' + 'registered domain needs a row, and every ledger domain must be a live prefix or a pinned legacy / ' + 'non-dispatch branch. That two-way guard is what settles the FILE-SELECTION layer by ' + 'construction — all 18 DomainRoute prefixes declared across the 17 domain files that declare one are ' + - 'ledger domains today, including the 11 files no probe names. 23 domains; 9 classified ' + + 'ledger domains today, including the 8 files no probe names. 23 domains; 12 classified ' + '(/meta, /actions, /automation, /packages, /mcp, /analytics, /i18n, /approvals/act, ' + - '/webhooks/redeliver), 14 in the shrink-only baseline.', + '/webhooks/redeliver, /notifications, /keys, /share-links), 11 in the shrink-only baseline.', }, { file: 'packages/rest/src/rest-server.ts', @@ -710,6 +724,51 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ controls: { 'shouldDenyAnonymous(': 1, handleI18nRequest: 3 }, note: 'Same shape as domains/actions.ts.', }, + { + // [ADR-0138 E2] The three domains whose anonymous decision moved from a + // face to the handler's first statement; each row has the actions.ts + // shape. The handler-name control counts every occurrence of the name as a + // substring: notifications.ts carries it twice (the declaration and the + // DomainRoute call site); keys.ts four times (those two and two comment + // mentions in the mint body); share-links.ts six times, because the body it + // delegates to is `handleShareLinksRequestBody` and two comments name it. + file: 'packages/runtime/src/domains/notifications.ts', + kinds: ['GATE_PIN'], + probes: 1, + keys: 1, + population: 1, + reachable: 1, + blindSpot: 0, + populationRule: '`export async function handle*Request` entry points', + controls: { 'shouldDenyAnonymous(': 1, handleNotificationRequest: 2 }, + note: 'Same shape as domains/actions.ts.', + }, + { + file: 'packages/runtime/src/domains/keys.ts', + kinds: ['GATE_PIN'], + probes: 1, + keys: 1, + population: 1, + reachable: 1, + blindSpot: 0, + populationRule: '`export async function handle*Request` entry points', + controls: { 'shouldDenyAnonymous(': 1, handleKeysRequest: 4 }, + note: 'Same shape as domains/actions.ts.', + }, + { + file: 'packages/runtime/src/domains/share-links.ts', + kinds: ['GATE_PIN'], + probes: 1, + keys: 1, + population: 1, + reachable: 1, + blindSpot: 0, + populationRule: '`export async function handle*Request` entry points', + controls: { 'shouldDenyAnonymous(': 1, handleShareLinksRequest: 6 }, + note: + 'Same shape as domains/actions.ts, with the one exception ADR-0138 D2 names for this domain: the two ' + + 'token faces (class 2) are selected past the floor by the predicate that marks them public.', + }, { file: 'packages/runtime/src/domains/mcp.ts', kinds: ['GATE_PIN'], @@ -1054,7 +1113,7 @@ export function deriveProbeFileCensus(): { }); } - // ── the six runtime domain files (GATE_PIN) ───────────────────────────── + // ── the eight runtime domain files whose floor is a GATE_PIN ───────────── // // [#22432] The handler-name class admits digits: `handleI18nRequest` is a // `handle*Request` entry point like the others, and a letters-only class @@ -1067,6 +1126,9 @@ export function deriveProbeFileCensus(): { ['packages/runtime/src/domains/packages.ts', 'shouldDenyAnonymous(', 'handlePackagesRequest'], ['packages/runtime/src/domains/analytics.ts', 'shouldDenyAnonymous(', 'handleAnalyticsRequest'], ['packages/runtime/src/domains/i18n.ts', 'shouldDenyAnonymous(', 'handleI18nRequest'], + ['packages/runtime/src/domains/notifications.ts', 'shouldDenyAnonymous(', 'handleNotificationRequest'], + ['packages/runtime/src/domains/keys.ts', 'shouldDenyAnonymous(', 'handleKeysRequest'], + ['packages/runtime/src/domains/share-links.ts', 'shouldDenyAnonymous(', 'handleShareLinksRequest'], ]; for (const [rel, gate, handler] of domains) { const src = read(rel); diff --git a/packages/qa/dogfood/test/authz-probe-blind-spot.test.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.test.ts index 558c5fa826f..2a0eab4726d 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.test.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.test.ts @@ -43,8 +43,8 @@ describe('authz probe blind-spot census (#13260)', () => { expect(derived.table).toEqual(PROBE_TABLE); }); - it('every classified key is accounted for — 22 `covers` keys, no more', () => { - // The matrix's `covers` keys number 22 today: the 9 probe-minted keys this + it('every classified key is accounted for — 29 `covers` keys, no more', () => { + // The matrix's `covers` keys number 29 today: the 9 probe-minted keys this // census was first measured against, plus the 6 ledger family/domain keys // classified when the population moved (2026-08-31), plus the `/analytics` // domain's gate-pin key and its dispatcher-domain key (#21061), plus the @@ -52,15 +52,18 @@ describe('authz probe blind-spot census (#13260)', () => { // key and its dispatcher-domain key (#22432), plus the `/approvals/act` // dispatcher-domain key, classified by `approval-action-token` (#22576), // plus the `/webhooks/redeliver` dispatcher-domain key, classified by - // `webhook-redeliver-session` (#22755). If a probe + // `webhook-redeliver-session` (#22755), plus — under ADR-0138 card E2 — + // the `/notifications`, `/keys` and `/share-links` domains' gate-pin keys + // and dispatcher-domain keys, and the `forms` REST family key, classified + // by the D2 class 1 row. If a probe // starts minting a key no row covers, the ratchet itself goes red as // UNCLASSIFIED — that is its job, and this pin does not duplicate it. What // this asserts is only that the census's key count is current. // - // ⚠️ This is NOT the size of the population. The ledgers mint 41 keys; 11 - // are classified here and 30 are enumerated in the shrink-only baseline, + // ⚠️ This is NOT the size of the population. The ledgers mint 41 keys; 15 + // are classified here and 26 are enumerated in the shrink-only baseline, // which `authz-conformance.test.ts` holds to its own four rules. - expect(PROBE_TABLE.keys).toBe(22); + expect(PROBE_TABLE.keys).toBe(29); }); it.each(PROBE_FILE_CENSUS.map((r) => [r.file, r] as const))( diff --git a/packages/runtime/src/declared-capability-absence-warn-once.test.ts b/packages/runtime/src/declared-capability-absence-warn-once.test.ts index a643630be43..63b40084996 100644 --- a/packages/runtime/src/declared-capability-absence-warn-once.test.ts +++ b/packages/runtime/src/declared-capability-absence-warn-once.test.ts @@ -65,6 +65,10 @@ import { sendError } from '@objectstack/types'; // `resolveExecutionContext` reads, answering a session for every request. Only // identity is stubbed; the route, the service and every expectation are // unchanged. Anonymity is pinned in `domains/analytics-anonymous-deny.test.ts`. +// [ADR-0138 D2] The `/notifications` domain refuses an anonymous caller first +// too, ahead of the declared 501 this file is about, so its boots register the +// same slot; anonymity there is pinned in +// `domains/domain-floor-anonymous-deny.test.ts`. const SIGNED_IN_AUTH = { api: { getSession: async () => ({ user: { id: 'usr_analytics_caller' } }) } }; // This budget was raised when every test re-executed the dispatcher's module @@ -184,7 +188,7 @@ const throwingAnalytics = (message: string) => ({ describe('#14656 — the dispatcher door', () => { it('reports a declared 501 ONCE across N requests, at warn, naming the missing service', async () => { - const { route, path, logger } = await boot({}); + const { route, path, logger } = await boot({ auth: SIGNED_IN_AUTH }); for (const _ of [0, 1, 2, 3, 4]) { await route('GET /notifications')({ ...REQ }, makeRes()); @@ -209,7 +213,7 @@ describe('#14656 — the dispatcher door', () => { }); it('a SECOND, DIFFERENT route still reports — the dedupe key is the route', async () => { - const { route, path, logger } = await boot({}); + const { route, path, logger } = await boot({ auth: SIGNED_IN_AUTH }); await route('GET /notifications')({ ...REQ }, makeRes()); await route('GET /notifications')({ ...REQ }, makeRes()); @@ -258,7 +262,7 @@ describe('#14656 — the dispatcher door', () => { describe('#14656 — both doors read ONE predicate, on one fixture envelope', () => { it('the envelope the dispatcher answers is classified identically by the REST envelope writer', async () => { - const { route, logger, sendError } = await boot({}); + const { route, logger, sendError } = await boot({ auth: SIGNED_IN_AUTH }); // ── Door 1: the runtime dispatcher, on the real route ────────────── const res = makeRes(); @@ -311,7 +315,7 @@ describe('#14656 — the wire does not move', () => { * report» — is a measurement, and this is the instrument. */ it('answers the same bytes it answered before this change, on every request', async () => { - const { route } = await boot({}); + const { route } = await boot({ auth: SIGNED_IN_AUTH }); const seen: string[] = []; for (const _ of [0, 1, 2]) { diff --git a/packages/runtime/src/dispatcher-5xx-always-logged.test.ts b/packages/runtime/src/dispatcher-5xx-always-logged.test.ts index 807a9d9aa9c..4b4a4380914 100644 --- a/packages/runtime/src/dispatcher-5xx-always-logged.test.ts +++ b/packages/runtime/src/dispatcher-5xx-always-logged.test.ts @@ -214,8 +214,11 @@ describe('#14310 — a 5xx is never silent', () => { // The one door-level guard kept HERE, so this file cannot go green // while believing `/notifications` still costs an `error` line. // Everything else about the exception is pinned next door in - // `declared-capability-absence-warn-once.test.ts`. - const { handlers, logger } = await boot({}); + // `declared-capability-absence-warn-once.test.ts`. Signed in: since + // ADR-0138 D2 the `/notifications` domain refuses an anonymous caller + // 401 as its first statement, ahead of the capability answer this case + // is about. + const { handlers, logger } = await boot({ auth: SIGNED_IN_AUTH }); const res = makeRes(); await handlers['GET /api/v1/notifications']({ body: {}, query: {}, headers: {}, params: {} }, res); diff --git a/packages/runtime/src/domain-handler-registry.test.ts b/packages/runtime/src/domain-handler-registry.test.ts index fb88cb2a4df..4ee31129c14 100644 --- a/packages/runtime/src/domain-handler-registry.test.ts +++ b/packages/runtime/src/domain-handler-registry.test.ts @@ -351,10 +351,20 @@ describe('HttpDispatcher extracted domains (PR-3: keys/storage/ui)', () => { it('GET /keys answers 405 (mint is POST-only), and /keysfoo is NOT claimed (segment semantics)', async () => { const dispatcher = makeDispatcher(); - const wrongMethod = await dispatcher.dispatch('GET', '/keys', undefined, {}, {} as any); + // ADR-0138 D2: the anonymous decision is the domain's FIRST statement, + // ahead of the method check, so an anonymous wrong-method request is the + // floor's 401, never the 405 describing the route's shape. + const anonymousWrongMethod = await dispatcher.dispatch('GET', '/keys', undefined, {}, {} as any); + expect(anonymousWrongMethod.response?.status).toBe(401); + expect(anonymousWrongMethod.response?.body?.error?.code).toBe('UNAUTHENTICATED'); + // …and the 405 still answers a signed-in caller. + const signedIn = { request: { headers: {} }, executionContext: { userId: 'u1', isSystem: false, positions: [], permissions: [] } }; + const wrongMethod = await dispatcher.handleKeys('GET', undefined, signedIn as any); expect(wrongMethod.response?.status).toBe(405); + // A claimed /keysfoo would now answer the floor's 401 (or the 405), so + // the segment boundary is asserted as the unclaimed answer itself. const lexical = await dispatcher.dispatch('POST', '/keysfoo', { name: 'k' }, {}, {} as any); - expect(lexical.response?.status ?? 404).not.toBe(405); + expect(lexical.response?.status ?? 404).toBe(404); }); it('POST /keys mints a key pinned to the caller (thin delegate carries the extracted body)', async () => { @@ -429,9 +439,18 @@ describe('HttpDispatcher extracted domains (PR-3: keys/storage/ui)', () => { describe('HttpDispatcher extracted domains (PR-4: share-links)', () => { it('/share-links responds 501 when the shareLinks service is absent', async () => { - const result = await makeDispatcher().dispatch('GET', '/share-links', undefined, {}, {} as any); + // Signed in: since ADR-0138 D2 moved the domain's anonymous decision to + // its first statement, an anonymous caller is refused 401 before the + // service is probed — asserted beside it. + const signedIn = { request: { headers: {} }, executionContext: { userId: 'u1', isSystem: false, positions: [], permissions: [] } }; + const result = await makeDispatcher().handleShareLinks('', 'GET', undefined, {}, signedIn as any); expect(result.handled).toBe(true); expect(result.response?.status).toBe(501); + + const anonymous = await makeDispatcher().dispatch('GET', '/share-links', undefined, {}, {} as any); + expect(anonymous.handled).toBe(true); + expect(anonymous.response?.status).toBe(401); + expect(anonymous.response?.body?.error?.code).toBe('UNAUTHENTICATED'); }); it('/share-linksfoo is NOT claimed (segment semantics)', async () => { diff --git a/packages/runtime/src/domains/domain-floor-anonymous-deny.test.ts b/packages/runtime/src/domains/domain-floor-anonymous-deny.test.ts new file mode 100644 index 00000000000..d644e5f3e4d --- /dev/null +++ b/packages/runtime/src/domains/domain-floor-anonymous-deny.test.ts @@ -0,0 +1,260 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0138 D2 — "The decision is taken once per domain, as the domain's first + * statement, never surface by surface". These three dispatcher domains decided + * it per face until the decision moved to their entry: + * + * - `/notifications` decided it below the inbox capability probe, so a + * deployment without an inbox answered an anonymous caller 501; + * - `/keys` decided it below the method check, so an anonymous caller using any + * verb but POST was answered 405; + * - `/share-links` decided it inside the management faces, below the service + * probe, so a deployment without the share-link service answered 501. + * + * ## What is pinned, per domain + * + * A. every face refuses an anonymous caller (both shapes the dispatcher + * produces: an unresolved context, and the guest envelope) with the + * dispatcher-wrapper `401 UNAUTHENTICATED`, before any service is resolved; + * B. A NEW ROUTE IN THE DOMAIN ANSWERS 401: a request on a path or verb no face + * serves yet is refused by the same statement, because the decision precedes + * route selection — a face added later is behind it from the day it lands; + * C. the capability answer an anonymous caller used to get first (501, 405) is + * now behind the floor; + * D. the signed-in control: a member still reaches every face exactly as + * before, so the 401s are the floor's answer and not a broken door; + * E. (`/share-links` only) the two token faces of class 2 stay admitted to an + * anonymous caller — the one exception the record names for this domain. + * + * ⛔ The envelope builder is the REAL one the dispatcher wires in + * (`apiErrorResponse`), so `error.code` is read where the wire carries it. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, +} from '@objectstack/core'; +import { SHARE_LINK_SERVICE } from '@objectstack/spec/contracts'; + +import { handleNotificationRequest } from './notifications.js'; +import { handleKeysRequest } from './keys.js'; +import { handleShareLinksRequest } from './share-links.js'; +import { apiErrorResponse } from '../error-envelope.js'; +import type { DomainHandlerDeps } from '../domain-handler-registry.js'; +import type { HttpProtocolContext } from '../http-dispatcher.js'; + +// ── contexts ──────────────────────────────────────────────────────────────── + +const anonUnresolved = () => ({ request: { headers: {} } }) as unknown as HttpProtocolContext; +/** What the dispatcher builds for an unauthenticated request: a guest, no user id. */ +const guestEnvelope = () => ({ + request: { headers: {} }, + executionContext: { + isSystem: false, principalKind: 'guest', positions: ['guest'], permissions: [], systemPermissions: [], + }, +}) as unknown as HttpProtocolContext; +const MEMBER_EC = { userId: 'usr_member', isSystem: false, positions: [], permissions: [], systemPermissions: [] }; +const member = () => ({ request: { headers: {} }, executionContext: { ...MEMBER_EC } }) as unknown as HttpProtocolContext; + +const ANONYMOUS_CONTEXTS: ReadonlyArray HttpProtocolContext]> = [ + ['an unresolved context', anonUnresolved], + ['the guest envelope', guestEnvelope], +]; + +// ── deps ──────────────────────────────────────────────────────────────────── + +function makeDeps(services: Record) { + const resolveService = vi.fn(async (_c: unknown, name: string) => services[name]); + const getObjectQL = vi.fn(async () => undefined); + const getRequestKernelService = vi.fn(async () => undefined); + const deps = { + resolveService, + getObjectQL, + getRequestKernelService, + success: (data: unknown) => ({ status: 200, body: { success: true, data } }), + error: (message: string, httpStatus = 500, details?: unknown) => + apiErrorResponse({ message, httpStatus, details }), + routeNotFound: (route: string) => apiErrorResponse({ message: `Route not found: ${route}`, httpStatus: 404 }), + errorFromThrown: (e: any, fallback = 500) => + apiErrorResponse({ message: String(e?.message ?? e), httpStatus: e?.status ?? fallback }), + } as unknown as DomainHandlerDeps; + return { deps, resolveService, getObjectQL, getRequestKernelService }; +} + +/** The ADR-0112 refusal envelope, dispatcher-wrapper family: status AND code, never one alone. */ +function expectAnonymousDenied(result: any) { + expect(result.handled).toBe(true); + expect(result.response.status).toBe(ANONYMOUS_DENY_STATUS); + expect(result.response.status).toBe(401); + expect(result.response.body.success).toBe(false); + expect(result.response.body.error.code).toBe(ANONYMOUS_DENY_CODE); + expect(result.response.body.error.code).toBe('UNAUTHENTICATED'); + expect(result.response.body.error.httpStatus).toBe(401); + expect(result.response.body.error.message).toBe(ANONYMOUS_DENY_MESSAGE); + expect(result.response.body.data).toBeUndefined(); +} + +function expectNothingResolved(seams: ReturnType) { + expect(seams.resolveService).not.toHaveBeenCalled(); + expect(seams.getObjectQL).not.toHaveBeenCalled(); + expect(seams.getRequestKernelService).not.toHaveBeenCalled(); +} + +// ── /notifications ────────────────────────────────────────────────────────── + +const inbox = () => ({ + listInbox: vi.fn(async () => ({ notifications: [], unreadCount: 0 })), + markRead: vi.fn(async () => ({ success: true, readCount: 0 })), + markAllRead: vi.fn(async () => ({ success: true, readCount: 0 })), +}); + +/** [sub-path, method, body] — the three served faces. */ +const NOTIFICATION_FACES = [ + ['', 'GET', undefined], + ['/read', 'POST', { ids: ['n1'] }], + ['/read/all', 'POST', undefined], +] as const; +/** Paths and verbs no face serves: what a route added later looks like today. */ +const NOTIFICATION_UNSERVED = [ + ['/anon-unrouted', 'GET'], + ['/read', 'DELETE'], + ['', 'PUT'], +] as const; + +describe('ADR-0138 D2 — /notifications decides anonymity once, as its first statement', () => { + describe.each(ANONYMOUS_CONTEXTS)('%s', (_label, ctx) => { + it.each(NOTIFICATION_FACES)('A — face %s %s is refused 401 and nothing is resolved', async (path, method, body) => { + const seams = makeDeps({ notification: inbox() }); + expectAnonymousDenied(await handleNotificationRequest(seams.deps, path, method, body, {}, ctx())); + expectNothingResolved(seams); + }); + + it.each(NOTIFICATION_UNSERVED)('B — a route the domain does not serve yet (%s %s) is refused 401', async (path, method) => { + const seams = makeDeps({ notification: inbox() }); + expectAnonymousDenied(await handleNotificationRequest(seams.deps, path, method, undefined, {}, ctx())); + expectNothingResolved(seams); + }); + + it('C — a deployment with no inbox answers 401, never the 501 capability disclosure', async () => { + const seams = makeDeps({}); + expectAnonymousDenied(await handleNotificationRequest(seams.deps, '', 'GET', undefined, {}, ctx())); + expectNothingResolved(seams); + }); + }); + + it('D — a member is served by the inbox with their own user id, and still gets the 501 with no inbox', async () => { + const service = inbox(); + const served: any = await handleNotificationRequest(makeDeps({ notification: service }).deps, '', 'GET', undefined, {}, member()); + expect(served.response.status).toBe(200); + expect(service.listInbox).toHaveBeenCalledWith('usr_member', { read: undefined, type: undefined, limit: undefined }); + + const empty: any = await handleNotificationRequest(makeDeps({}).deps, '', 'GET', undefined, {}, member()); + expect(empty.response.status).toBe(501); + }); +}); + +// ── /keys ─────────────────────────────────────────────────────────────────── + +describe('ADR-0138 D2 — /keys decides anonymity once, as its first statement', () => { + describe.each(ANONYMOUS_CONTEXTS)('%s', (_label, ctx) => { + it('A — the mint face (POST) is refused 401 and nothing is resolved', async () => { + const seams = makeDeps({}); + expectAnonymousDenied(await handleKeysRequest(seams.deps, 'POST', { name: 'k' }, ctx())); + expectNothingResolved(seams); + }); + + it.each(['GET', 'PUT', 'DELETE', 'PATCH'])('B/C — a verb no face serves (%s) is refused 401, never the 405', async (method) => { + const seams = makeDeps({}); + expectAnonymousDenied(await handleKeysRequest(seams.deps, method, undefined, ctx())); + expectNothingResolved(seams); + }); + }); + + it('D — a member still gets the 405 for a wrong verb, and reaches the data service on POST', async () => { + const wrongVerb: any = await handleKeysRequest(makeDeps({}).deps, 'GET', undefined, member()); + expect(wrongVerb.response.status).toBe(405); + + const seams = makeDeps({}); + const mint: any = await handleKeysRequest(seams.deps, 'POST', { name: 'k' }, member()); + // No data service in this harness: the member got past the floor and the + // method check, and was told the data service is absent. + expect(mint.response.status).toBe(503); + expect(seams.getObjectQL).toHaveBeenCalledTimes(1); + }); +}); + +// ── /share-links ──────────────────────────────────────────────────────────── + +const shareLinkService = () => ({ + createLink: vi.fn(async () => ({ id: 'lnk_1' })), + listLinks: vi.fn(async () => []), + revokeLink: vi.fn(async () => undefined), + resolveToken: vi.fn(async () => null), +}); + +/** [sub-path, method, body] — the management faces. */ +const SHARE_LINK_MANAGEMENT_FACES = [ + ['', 'POST', { object: 'doc', recordId: 'd1' }], + ['', 'GET', undefined], + ['/lnk_1', 'DELETE', undefined], +] as const; +/** Paths and verbs no face serves, including the token routes under a verb they do not take. */ +const SHARE_LINK_UNSERVED = [ + ['/a/b/c', 'GET'], + ['/tok_1/resolve', 'POST'], + ['/tok_1/messages', 'DELETE'], + ['/tok_1/anon-unrouted', 'GET'], +] as const; + +describe('ADR-0138 D2 — /share-links decides anonymity once, as its first statement, class 2 excepted', () => { + describe.each(ANONYMOUS_CONTEXTS)('%s', (_label, ctx) => { + it.each(SHARE_LINK_MANAGEMENT_FACES)('A — management face %s %s is refused 401 and nothing is resolved', async (path, method, body) => { + const service = shareLinkService(); + const seams = makeDeps({ [SHARE_LINK_SERVICE]: service }); + expectAnonymousDenied(await handleShareLinksRequest(seams.deps, path, method, body, {}, ctx())); + expectNothingResolved(seams); + expect(service.createLink).not.toHaveBeenCalled(); + expect(service.listLinks).not.toHaveBeenCalled(); + expect(service.revokeLink).not.toHaveBeenCalled(); + }); + + it.each(SHARE_LINK_UNSERVED)('B — a route the domain does not serve yet (%s %s) is refused 401', async (path, method) => { + const seams = makeDeps({ [SHARE_LINK_SERVICE]: shareLinkService() }); + expectAnonymousDenied(await handleShareLinksRequest(seams.deps, path, method, undefined, {}, ctx())); + expectNothingResolved(seams); + }); + + it('C — a deployment with no share-link service answers 401 on a management face, never the 501', async () => { + const seams = makeDeps({}); + expectAnonymousDenied(await handleShareLinksRequest(seams.deps, '', 'GET', undefined, {}, ctx())); + expectNothingResolved(seams); + }); + + it.each(['resolve', 'messages'])('E — the class 2 token face /:token/%s stays admitted: the token decides', async (face) => { + const service = shareLinkService(); + const seams = makeDeps({ [SHARE_LINK_SERVICE]: service }); + const result: any = await handleShareLinksRequest(seams.deps, `/tok_1/${face}`, 'GET', undefined, {}, ctx()); + // The service was asked about the token; the refusal, if any, is the + // token's answer (an unknown token here), never the anonymous 401. + expect(service.resolveToken).toHaveBeenCalledTimes(1); + expect(result.response.body?.error?.code).not.toBe('UNAUTHENTICATED'); + expect(result.response.status).not.toBe(401); + }); + }); + + it('D — a member still reaches every management face', async () => { + const service = shareLinkService(); + const deps = makeDeps({ [SHARE_LINK_SERVICE]: service }).deps; + expect(((await handleShareLinksRequest(deps, '', 'GET', undefined, {}, member())) as any).response.status).toBe(200); + expect(service.listLinks).toHaveBeenCalledWith(expect.objectContaining({ createdBy: 'usr_member' }), expect.objectContaining(MEMBER_EC)); + expect(((await handleShareLinksRequest(deps, '/lnk_1', 'DELETE', undefined, {}, member())) as any).response.status).toBe(200); + expect(service.revokeLink).toHaveBeenCalledTimes(1); + const created: any = await handleShareLinksRequest(deps, '', 'POST', { object: 'doc', recordId: 'd1' }, {}, member()); + expect(created.response.status).toBe(201); + expect(service.createLink).toHaveBeenCalledTimes(1); + // …and with no service, a member gets the 501 the domain always answered. + const empty: any = await handleShareLinksRequest(makeDeps({}).deps, '', 'GET', undefined, {}, member()); + expect(empty.response.status).toBe(501); + }); +}); diff --git a/packages/runtime/src/domains/keys.ts b/packages/runtime/src/domains/keys.ts index 46c17978487..c5be489cfaf 100644 --- a/packages/runtime/src/domains/keys.ts +++ b/packages/runtime/src/domains/keys.ts @@ -10,6 +10,8 @@ * Security (zero-tolerance): * - Requires an authenticated principal; `user_id` is PINNED to that * caller and is NEVER read from the request body (no impersonation). + * The anonymous decision is the domain's FIRST statement (ADR-0138 D2), + * ahead of the method check, so every face answers an anonymous caller 401. * - Body is whitelisted to `name` (+ optional `expires_at`); any * `key` / `id` / `user_id` / `revoked` in the body is ignored, so a * caller cannot forge a known-secret or escalate. @@ -31,7 +33,10 @@ * organization is re-checked here, against `sys_member`, at mint time. */ -import { isGrantActive, effectiveTenancyPosture, AuthzStoreUnavailableError } from '@objectstack/core'; +import { + isGrantActive, effectiveTenancyPosture, AuthzStoreUnavailableError, + shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, +} from '@objectstack/core'; import { postureEnforcesWall } from '@objectstack/spec/security'; import { generateApiKey } from '../security/api-key.js'; @@ -65,13 +70,25 @@ export async function handleKeysRequest( body: any, context: HttpProtocolContext, ): Promise { - if (method !== 'POST') { - return { handled: true, response: deps.error('Method not allowed', 405) }; + // ANONYMOUS BASELINE — the domain's FIRST statement (ADR-0138 D2: the + // decision is taken once per domain, never face by face). It used to sit + // below the method check, so an anonymous caller using any verb but POST + // was answered the 405 describing the route's shape instead of the 401 + // every domain off the closed list of doors answers. A key is minted FOR a + // user, so the decision is asked about the user id alone: this domain has + // no system-context arm (an internal system context was refused here before + // as it is now), and the `!ec` / `!ec.userId` arms only narrow the type for + // the compiler. + const ec = context.executionContext; + if (shouldDenyAnonymous({ userId: ec?.userId }) || !ec || !ec.userId) { + return { + handled: true, + response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), + }; } - const ec = context.executionContext; - if (!ec || !ec.userId) { - return { handled: true, response: deps.error('Unauthorized: sign in to generate an API key', 401) }; + if (method !== 'POST') { + return { handled: true, response: deps.error('Method not allowed', 405) }; } // ── Whitelist the body. Only `name` and optional `expires_at`. ── diff --git a/packages/runtime/src/domains/notifications.ts b/packages/runtime/src/domains/notifications.ts index bfd646bba5e..3e022849c27 100644 --- a/packages/runtime/src/domains/notifications.ts +++ b/packages/runtime/src/domains/notifications.ts @@ -8,6 +8,12 @@ * the receipt keyed `(notification_id, user_id, channel:'inbox')`. The * routes are `auth: true`, so an authenticated user is required. * + * ADR-0138 D2: the anonymous decision is this domain's FIRST statement + * (`shouldDenyAnonymous`), taken once for every face — ahead of the inbox + * capability probe, so a route added here later is behind it from the day it + * lands, and an anonymous caller is answered 401 whether or not the deployment + * carries an inbox. + * * NOTE (cross-repo, see #2462 step-① re-scope): this domain has NO other * HTTP owner anywhere — cloud hosts reach it through the `dispatch()` * delegation, so this handler must keep working from the registry exactly @@ -19,6 +25,9 @@ * POST /read/all → markAllRead */ +import { + shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, +} from '@objectstack/core'; import { CoreServiceName } from '@objectstack/spec/system'; import { MarkNotificationsReadRequestSchema } from '@objectstack/spec/api'; import type { INotificationService } from '@objectstack/spec/contracts'; @@ -105,6 +114,27 @@ export async function handleNotificationRequest( query: any, context: HttpProtocolContext, ): Promise { + // ANONYMOUS BASELINE — the domain's FIRST statement (ADR-0138 D2: the + // decision is taken once per domain, never face by face). It used to sit + // below the inbox capability probe as this domain's own `!userId` check, + // so a deployment without an inbox answered an anonymous caller the 501 + // capability disclosure instead of the 401 every domain off the closed + // list of doors answers. Decided here, before the slot is even resolved, + // an anonymous caller learns nothing about the deployment, and a face added + // below inherits the refusal instead of having to remember its own. + // + // The inbox is per user, so the decision is asked about the user id alone: + // this domain has no system-context arm (an internal system context carries + // no inbox to read, and was refused here before as it is now), and the + // `!userId` arm only narrows the type for the compiler. + const userId: string | undefined = context.executionContext?.userId; + if (shouldDenyAnonymous({ userId }) || !userId) { + return { + handled: true, + response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), + }; + } + // [#4127] Typed against the contract instead of `as any`, so a call this // file makes that `INotificationService` does not declare is a compile // error rather than a runtime discovery. That is the check missing when @@ -141,11 +171,6 @@ export async function handleNotificationRequest( // Narrowed for the routes below: the entry probe established `listInbox`. const inbox = service as INotificationService & Required>; - const userId: string | undefined = context.executionContext?.userId; - if (!userId) { - return { handled: true, response: deps.error('Authentication required', 401) }; - } - const m = method.toUpperCase(); // split+filter drops leading/trailing/duplicate slashes without a regex // over request-controlled input (CodeQL js/polynomial-redos) — same diff --git a/packages/runtime/src/domains/share-links-public-cache-headers.test.ts b/packages/runtime/src/domains/share-links-public-cache-headers.test.ts index 59c57f8b0df..68d1bd9885f 100644 --- a/packages/runtime/src/domains/share-links-public-cache-headers.test.ts +++ b/packages/runtime/src/domains/share-links-public-cache-headers.test.ts @@ -168,8 +168,12 @@ describe('[#21839] a throw outside the body try still carries the public headers }); it('an authenticated route still propagates the throw unchanged', async () => { + // Signed in: an anonymous caller is refused 401 by the domain's anonymous + // baseline before any service is resolved (ADR-0138 D2), so the throw is + // only reachable for a caller the baseline admits. + const signedIn = { executionContext: { userId: 'usr_21839', isSystem: false } } as unknown as HttpProtocolContext; await expect( - handleShareLinksRequest(throwingDeps(), '', 'GET', undefined, {}, {} as HttpProtocolContext), + handleShareLinksRequest(throwingDeps(), '', 'GET', undefined, {}, signedIn), ).rejects.toThrow('service registry unavailable'); }); }); diff --git a/packages/runtime/src/domains/share-links.ts b/packages/runtime/src/domains/share-links.ts index a3409536077..08dba3957b6 100644 --- a/packages/runtime/src/domains/share-links.ts +++ b/packages/runtime/src/domains/share-links.ts @@ -24,6 +24,15 @@ * context (per-env RLS is bypassed because the token gates access), and * `redactFields` are stripped before the record leaves the server. * + * Those two faces are ADR-0138 D2's class 2 (share links), on this, the + * dispatcher twin of the plugin-sharing mount. Every OTHER face is behind the + * domain's anonymous decision, taken once as the domain's first statement + * (`handleShareLinksRequest`): the one exception it makes is named by the same + * predicate that marks the two public faces (`isPublicShareLinkRoute`), so a + * face added here later is refused to an anonymous caller from the day it + * lands, and a new public face is an amendment of that record, never an edit + * here alone. + * * Every route answers the declared `{ success: true, data }` envelope with `data` * carrying the payload directly. Create and list used to emit a duplicate * top-level `link` / `links` beside `data` — a producer-side shim for readers @@ -35,6 +44,9 @@ * value whichever one served the request. */ +import { + shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, +} from '@objectstack/core'; import { SHARE_LINK_SERVICE } from '@objectstack/spec/contracts'; // [#14637 -> #14935] The standing `publicSharing.enabled` switch, read through // the ONE predicate the package that DECLARES the key exports. This file used @@ -109,6 +121,24 @@ export async function handleShareLinksRequest( context: HttpProtocolContext, ): Promise { const isPublic = isPublicShareLinkRoute(subPath, method); + // ANONYMOUS BASELINE — the domain's first decision (ADR-0138 D2: taken once + // per domain, never face by face), with the one exception the record names + // for this domain: the two token faces of class 2, which the capability + // token admits. It used to sit inside the body, below the service probe, as + // the management faces' own `!userId` check, so a deployment without the + // share-link service answered an anonymous caller the 501 capability + // disclosure instead of the 401. A link is managed BY a user, so the + // decision is asked about the user id alone: these faces have no + // system-context arm (an internal system context was refused here before + // as it is now). + if (!isPublic) { + if (shouldDenyAnonymous({ userId: context.executionContext?.userId })) { + return { + handled: true, + response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), + }; + } + } let result: HttpDispatcherResult; try { result = await handleShareLinksRequestBody(deps, subPath, method, body, query, context); @@ -350,7 +380,11 @@ async function handleShareLinksRequestBody( } // ── AUTHENTICATED: create / list / revoke ───────────────────── - if (!ec?.userId) return sendErr(401, 'UNAUTHENTICATED', 'Sign in to manage share links'); + // Not a decision: the domain's anonymous baseline in + // `handleShareLinksRequest` refused every caller without a user id + // before this body ran. This narrows `ec` for the compiler, and answers + // exactly what that baseline answers if it is ever reached. + if (!ec?.userId) return sendErr(ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE); // POST /share-links → create if (parts.length === 0 && m === 'POST') { diff --git a/packages/runtime/src/notification-schema-conformance.test.ts b/packages/runtime/src/notification-schema-conformance.test.ts index a3801b6fe46..f06bf19da8a 100644 --- a/packages/runtime/src/notification-schema-conformance.test.ts +++ b/packages/runtime/src/notification-schema-conformance.test.ts @@ -31,8 +31,9 @@ // eight `return`s of `domains/notifications.ts`, and three of them are the ones // carrying a `responseSchema`: // -// 1 :79 capabilityUnavailable — no service / not serveable / no `listInbox` -// 2 :86 deps.error('Authentication required', 401) +// 2 the anonymous baseline — the domain's FIRST statement since ADR-0138 +// D2 moved it ahead of the capability probe: 401 UNAUTHENTICATED +// 1 capabilityUnavailable — no service / not serveable / no `listInbox` // 3 :111 deps.success(listInbox result) ← ListNotificationsResponseSchema // 4 :116 capabilityUnavailable — `markRead` absent // 5 :128 throw validationFailure(...) — malformed mark-read body @@ -351,7 +352,8 @@ describe('[#5792] /notifications conforms to the schemas the catalog declares', }); // ═══════════════════════════════════════════════════════════════════════ - // Emit site 2 — the auth gate + // Emit site 2 — the anonymous baseline (ADR-0138 D2: the domain's first + // statement, so it answers ahead of emit site 1) // ═══════════════════════════════════════════════════════════════════════ it('emit site 2 — an unauthenticated caller gets 401 in the declared envelope', async () => { const result = await makeDispatcher().handleNotification('', 'GET', undefined, {}, ANON_CTX); @@ -359,7 +361,17 @@ describe('[#5792] /notifications conforms to the schemas the catalog declares', expect(result.handled).toBe(true); expect(result.response?.status).toBe(401); expectEnvelope(result.response?.body, false); - expect(result.response?.body?.error?.message).toBe('Authentication required'); + expect(result.response?.body?.error?.code).toBe('UNAUTHENTICATED'); + expect(result.response?.body?.error?.httpStatus).toBe(401); + }); + + it('emit site 2 answers ahead of emit site 1 — an empty slot is still 401 to an anonymous caller', async () => { + const result = await makeDispatcher(null).handleNotification('', 'GET', undefined, {}, ANON_CTX); + + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(401); + expectEnvelope(result.response?.body, false); + expect(result.response?.body?.error?.code).toBe('UNAUTHENTICATED'); }); // ═══════════════════════════════════════════════════════════════════════