From ebfe6fa743e9e84b185b41c6fcac88e02e9ba300 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 00:38:33 +0000 Subject: [PATCH 1/4] fix(runtime)!: the /ui and /data dispatcher domains decide an anonymous request as their first statement (ADR-0138 D2) WIP: the two domain floors and their unit cases. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015sSBzUJfLxTo3ZZ68LLfCG --- packages/runtime/src/domains/data.ts | 33 ++++ .../domain-floor-anonymous-deny.test.ts | 170 +++++++++++++++++- packages/runtime/src/domains/ui.ts | 28 +++ 3 files changed, 226 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/domains/data.ts b/packages/runtime/src/domains/data.ts index a5af09c1c77..501262a9fc4 100644 --- a/packages/runtime/src/domains/data.ts +++ b/packages/runtime/src/domains/data.ts @@ -13,8 +13,19 @@ * dispatcher state and restarts the accretion D11 decomposed. Dispatcher * facilities are reachable only through `DomainHandlerDeps`. Anchored in * scripts/adr-anchors/. + * + * ADR-0138 D2: the anonymous decision is this domain's FIRST statement + * (`shouldDenyAnonymous`), taken once for every face — ahead of the path + * parse, the environment check and every CRUD face, so a face added here + * later is behind it from the day it lands, and an unauthenticated caller is + * answered 401 whichever face it names. No D2 door class runs through this + * domain: a declared `object_operation` endpoint (class 4) reaches `callData` + * from the transport's fallback seam, never through this handler. */ +import { + shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, +} from '@objectstack/core'; import * as actionExec from '../action-execution.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; @@ -35,6 +46,28 @@ export function createDataDomain(deps: DomainHandlerDeps): DomainRoute { * path: sub-path after /data/ (e.g. "contacts", "contacts/123", "contacts/query") */ export async function handleDataRequest(deps: DomainHandlerDeps, path: string, method: string, body: any, 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). This domain made + // no anonymous decision of its own: an unauthenticated caller reached the + // face it named and was answered by that face — the CRUD gate's refusal of + // the guest envelope, a validation answer, the environment check — instead + // of the 401 every domain off the closed list of doors answers. Decided + // here, before the path is even parsed, the caller learns nothing about the + // object it named, and a face added below inherits the refusal. + // + // Asked about the user id alone, as the `/notifications` and `/keys` floors + // are: this is a transport for a caller on the wire, and `dispatch()` + // re-resolves identity for every request, so a caller arrives here as a + // user or as the guest — the system context is never an inbound principal. + // `method` rides along so a CORS preflight keeps the pass every other floor + // gives it. + if (shouldDenyAnonymous({ userId: _context.executionContext?.userId, method })) { + return { + handled: true, + response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), + }; + } + const parts = path.replace(/^\/+/, '').split('/'); const objectName = parts[0]; diff --git a/packages/runtime/src/domains/domain-floor-anonymous-deny.test.ts b/packages/runtime/src/domains/domain-floor-anonymous-deny.test.ts index d644e5f3e4d..a63a697b225 100644 --- a/packages/runtime/src/domains/domain-floor-anonymous-deny.test.ts +++ b/packages/runtime/src/domains/domain-floor-anonymous-deny.test.ts @@ -10,7 +10,11 @@ * - `/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. + * probe, so a deployment without the share-link service answered 501; + * - `/ui` and `/data` made no anonymous decision of their own: the face an + * unauthenticated caller named answered it — `/data` through the CRUD + * gate's refusal of the guest envelope (403), a validation answer (400) or + * the environment check (428), `/ui` with whatever its one face did. * * ## What is pinned, per domain * @@ -20,8 +24,8 @@ * 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; + * C. the capability answer an anonymous caller used to get first (501, 405 — + * and on `/ui` and `/data`, 400 and 428) 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 @@ -40,6 +44,8 @@ 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 { handleUiRequest } from './ui.js'; +import { handleDataRequest } from './data.js'; import { apiErrorResponse } from '../error-envelope.js'; import type { DomainHandlerDeps } from '../domain-handler-registry.js'; import type { HttpProtocolContext } from '../http-dispatcher.js'; @@ -64,14 +70,16 @@ const ANONYMOUS_CONTEXTS: ReadonlyArray HttpProtocolCont // ── deps ──────────────────────────────────────────────────────────────────── -function makeDeps(services: Record) { +function makeDeps(services: Record, opts: { multiTenant?: boolean } = {}) { const resolveService = vi.fn(async (_c: unknown, name: string) => services[name]); const getObjectQL = vi.fn(async () => undefined); const getRequestKernelService = vi.fn(async () => undefined); + const isMultiTenantHost = vi.fn(() => opts.multiTenant === true); const deps = { resolveService, getObjectQL, getRequestKernelService, + isMultiTenantHost, success: (data: unknown) => ({ status: 200, body: { success: true, data } }), error: (message: string, httpStatus = 500, details?: unknown) => apiErrorResponse({ message, httpStatus, details }), @@ -79,7 +87,7 @@ function makeDeps(services: Record) { errorFromThrown: (e: any, fallback = 500) => apiErrorResponse({ message: String(e?.message ?? e), httpStatus: e?.status ?? fallback }), } as unknown as DomainHandlerDeps; - return { deps, resolveService, getObjectQL, getRequestKernelService }; + return { deps, resolveService, getObjectQL, getRequestKernelService, isMultiTenantHost }; } /** The ADR-0112 refusal envelope, dispatcher-wrapper family: status AND code, never one alone. */ @@ -258,3 +266,155 @@ describe('ADR-0138 D2 — /share-links decides anonymity once, as its first stat expect(empty.response.status).toBe(501); }); }); + +// ── /ui ───────────────────────────────────────────────────────────────────── + +const viewProtocol = () => ({ + getUiView: vi.fn(async ({ object, type }: { object: string; type: string }) => ({ object, type, view: {} })), +}); + +/** [sub-path, query] — the one served face, under each spelling of its type. */ +const UI_FACES = [ + ['/view/doc', {}], + ['/view/doc/form', {}], + ['/view/doc', { type: 'form' }], +] as const; +/** Paths no face serves: what a route added later looks like today. */ +const UI_UNSERVED = ['/anon-unrouted', '/view', '/views/doc', ''] as const; + +describe('ADR-0138 D2 — /ui decides anonymity once, as its first statement', () => { + describe.each(ANONYMOUS_CONTEXTS)('%s', (_label, ctx) => { + it.each(UI_FACES)('A — face %s %o is refused 401 and nothing is resolved', async (path, query) => { + const protocol = viewProtocol(); + const seams = makeDeps({ protocol }); + expectAnonymousDenied(await handleUiRequest(seams.deps, path, query, ctx())); + expectNothingResolved(seams); + expect(protocol.getUiView).not.toHaveBeenCalled(); + }); + + it.each(UI_UNSERVED)('B — a route the domain does not serve yet (%j) is refused 401', async (path) => { + const seams = makeDeps({ protocol: viewProtocol() }); + expectAnonymousDenied(await handleUiRequest(seams.deps, path, {}, ctx())); + expectNothingResolved(seams); + }); + + it('C — no protocol service answers 401, never the 501; a repeated type answers 401, never the 400', async () => { + const empty = makeDeps({}); + expectAnonymousDenied(await handleUiRequest(empty.deps, '/view/doc', {}, ctx())); + expectNothingResolved(empty); + + const repeated = makeDeps({ protocol: viewProtocol() }); + expectAnonymousDenied(await handleUiRequest(repeated.deps, '/view/doc', { type: ['list', 'form'] }, ctx())); + expectNothingResolved(repeated); + }); + }); + + it('D — a member is served the view, still gets the 501 with no protocol, and the 400 for a repeated type', async () => { + const protocol = viewProtocol(); + const served: any = await handleUiRequest(makeDeps({ protocol }).deps, '/view/doc/form', {}, member()); + expect(served.response.status).toBe(200); + expect(protocol.getUiView).toHaveBeenCalledWith({ object: 'doc', type: 'form' }); + + const empty: any = await handleUiRequest(makeDeps({}).deps, '/view/doc', {}, member()); + expect(empty.response.status).toBe(501); + + const repeated: any = await handleUiRequest(makeDeps({ protocol }).deps, '/view/doc', { type: ['list', 'form'] }, member()); + expect(repeated.response.status).toBe(400); + expect(repeated.response.body.error.code).toBe('VALIDATION_ERROR'); + + const unserved: any = await handleUiRequest(makeDeps({ protocol }).deps, '/anon-unrouted', {}, member()); + expect(unserved.handled).toBe(false); + }); +}); + +// ── /data ─────────────────────────────────────────────────────────────────── + +const dataProtocol = () => ({ + findData: vi.fn(async ({ object }: { object: string }) => ({ object, records: [], total: 0 })), + getData: vi.fn(async ({ object, id }: { object: string; id: string }) => ({ object, id, record: { id } })), + createData: vi.fn(async ({ object }: { object: string }) => ({ object, id: 'r1', record: { id: 'r1' } })), + updateData: vi.fn(async ({ object, id }: { object: string; id: string }) => ({ object, id, record: { id } })), + deleteData: vi.fn(async ({ object, id }: { object: string; id: string }) => ({ object, id, success: true })), +}); + +/** [sub-path, method, body] — every served face: the three reads and the three writes. */ +const DATA_FACES = [ + ['/doc', 'GET', undefined], + ['/doc/r1', 'GET', undefined], + ['/doc/query', 'POST', { where: {} }], + ['/doc', 'POST', { name: 'x' }], + ['/doc/r1', 'PATCH', { name: 'y' }], + ['/doc/r1', 'DELETE', undefined], +] as const; +/** Paths and verbs no face serves: what a route added later looks like today. */ +const DATA_UNSERVED = [ + ['/doc/a/b/c', 'GET'], + ['/doc', 'PUT'], + ['/doc/r1', 'POST'], + ['/doc/r1', 'PUT'], +] as const; + +describe('ADR-0138 D2 — /data decides anonymity once, as its first statement', () => { + describe.each(ANONYMOUS_CONTEXTS)('%s', (_label, ctx) => { + it.each(DATA_FACES)('A — face %s %s is refused 401 and nothing is resolved', async (path, method, body) => { + const protocol = dataProtocol(); + const seams = makeDeps({ protocol }); + expectAnonymousDenied(await handleDataRequest(seams.deps, path, method, body, {}, ctx())); + expectNothingResolved(seams); + expect(seams.isMultiTenantHost).not.toHaveBeenCalled(); + for (const verb of Object.values(protocol)) expect(verb).not.toHaveBeenCalled(); + }); + + it.each(DATA_UNSERVED)('B — a route the domain does not serve yet (%s %s) is refused 401', async (path, method) => { + const seams = makeDeps({ protocol: dataProtocol() }); + expectAnonymousDenied(await handleDataRequest(seams.deps, path, method, undefined, {}, ctx())); + expectNothingResolved(seams); + }); + + it('C — no object (400), an unresolved environment (428) and a repeated filter (400) all answer 401 first', async () => { + const noObject = makeDeps({ protocol: dataProtocol() }); + expectAnonymousDenied(await handleDataRequest(noObject.deps, '', 'GET', undefined, {}, ctx())); + expectNothingResolved(noObject); + + const multiTenant = makeDeps({ protocol: dataProtocol() }, { multiTenant: true }); + expectAnonymousDenied(await handleDataRequest(multiTenant.deps, '/doc', 'GET', undefined, {}, ctx())); + expect(multiTenant.isMultiTenantHost).not.toHaveBeenCalled(); + expectNothingResolved(multiTenant); + + const repeated = makeDeps({ protocol: dataProtocol() }); + expectAnonymousDenied(await handleDataRequest(repeated.deps, '/doc', 'GET', undefined, { filter: ['a', 'b'] }, ctx())); + expectNothingResolved(repeated); + }); + + it('a CORS preflight keeps its pass: OPTIONS is not refused, and no face serves it', async () => { + const seams = makeDeps({ protocol: dataProtocol() }); + const result: any = await handleDataRequest(seams.deps, '/doc', 'OPTIONS', undefined, {}, ctx()); + expect(result.handled).toBe(false); + }); + }); + + it('D — a member reaches every face with their own context, and keeps the 400 and 428 answers', async () => { + const protocol = dataProtocol(); + const deps = makeDeps({ protocol }).deps; + const statuses: number[] = []; + for (const [path, method, body] of DATA_FACES) { + const r: any = await handleDataRequest(deps, path, method, body, {}, member()); + statuses.push(r.response.status); + } + expect(statuses).toEqual([200, 200, 200, 201, 200, 200]); + for (const verb of Object.values(protocol)) { + expect(verb).toHaveBeenCalledWith(expect.objectContaining({ object: 'doc', context: expect.objectContaining(MEMBER_EC) })); + } + + const noObject: any = await handleDataRequest(makeDeps({ protocol }).deps, '', 'GET', undefined, {}, member()); + expect(noObject.response.status).toBe(400); + + const multiTenant: any = await handleDataRequest( + makeDeps({ protocol }, { multiTenant: true }).deps, '/doc', 'GET', undefined, {}, member(), + ); + expect(multiTenant.response.status).toBe(428); + + const unserved: any = await handleDataRequest(makeDeps({ protocol }).deps, '/doc/a/b/c', 'GET', undefined, {}, member()); + expect(unserved.handled).toBe(false); + }); +}); diff --git a/packages/runtime/src/domains/ui.ts b/packages/runtime/src/domains/ui.ts index 2adac685ddd..5eb283169ae 100644 --- a/packages/runtime/src/domains/ui.ts +++ b/packages/runtime/src/domains/ui.ts @@ -6,8 +6,17 @@ * * Routes (path is the sub-path after `/ui`): * GET /view/:object[/:type] → getUiView (type also accepted as ?type=) + * + * ADR-0138 D2: the anonymous decision is this domain's FIRST statement + * (`shouldDenyAnonymous`), taken once for every face — ahead of the path + * parse and the protocol probe, so a face added here later is behind it from + * the day it lands, and an unauthenticated caller is answered 401 whatever it + * names. No D2 door class is served from this domain. */ +import { + shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, +} from '@objectstack/core'; // [#15238] The DECLARED protocol contract this domain's ONE request literal is // compiled against. Imported, never restated: a hand-written `getUiView(...)` // signature here would silently drift from the one the spec declares and @@ -94,6 +103,25 @@ export async function handleUiRequest( 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). This domain made + // no anonymous decision of its own, so whether an unauthenticated caller + // was refused depended on what the face it reached happened to do. Decided + // here, before the path is parsed or the protocol slot resolved, the caller + // learns nothing about the object it named or the deployment, and a face + // added below inherits the refusal. + // + // This handler takes no method: every face it serves is a read, and the + // registry hands it every verb, so the decision is asked of the caller + // alone. Asked about the user id alone, as the `/data` floor is: a caller + // on the wire arrives as a user or as the guest, never as `isSystem`. + if (shouldDenyAnonymous({ userId: _context.executionContext?.userId })) { + return { + handled: true, + response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), + }; + } + const parts = path.replace(/^\/+/, '').split('/').filter(Boolean); // GET /ui/view/:object (with optional type param) From fdffbc28f4b643f3625fc0e389559c215335c7de Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 00:58:45 +0000 Subject: [PATCH 2/4] test(runtime): sign in the cases that reached /ui and /data anonymously only incidentally, and pin the floor's 401 beside them Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015sSBzUJfLxTo3ZZ68LLfCG --- .../src/domain-handler-registry.test.ts | 15 +++++- .../data-permission-denied-envelope.test.ts | 22 ++++++++- ...her.permission-denied-user-message.test.ts | 9 +++- .../http-dispatcher.scoped-url-strip.test.ts | 17 +++++-- packages/runtime/src/http-dispatcher.test.ts | 49 ++++++++++++++----- 5 files changed, 90 insertions(+), 22 deletions(-) diff --git a/packages/runtime/src/domain-handler-registry.test.ts b/packages/runtime/src/domain-handler-registry.test.ts index 4ee31129c14..65f1d1edfc8 100644 --- a/packages/runtime/src/domain-handler-registry.test.ts +++ b/packages/runtime/src/domain-handler-registry.test.ts @@ -419,17 +419,28 @@ describe('HttpDispatcher extracted domains (PR-3: keys/storage/ui)', () => { }); it('/ui/view/:object serves the protocol getUiView result; 501 without a protocol service', async () => { + // Signed in through the dispatcher's own identity step: since ADR-0138 + // D2 the domain refuses an anonymous caller 401 as its first statement, + // ahead of the protocol probe — asserted below. + const auth = { api: { getSession: async () => ({ user: { id: 'u1' } }) } }; const getUiView = vi.fn().mockResolvedValue({ view: 'list-def' }); - const ok = await makeDispatcher({ protocol: { getUiView } }).dispatch('GET', '/ui/view/account/list', undefined, {}, {} as any); + const ok = await makeDispatcher({ protocol: { getUiView }, auth }).dispatch('GET', '/ui/view/account/list', undefined, {}, {} as any); expect(ok.response?.status).toBe(200); expect(getUiView).toHaveBeenCalledWith({ object: 'account', type: 'list' }); // [#4093 follow-up] Was 503. The route is mounted and the // implementation is absent — 501. 503 claimed the condition was // temporary; an uninstalled MetadataPlugin does not install itself. - const missing = await makeDispatcher().dispatch('GET', '/ui/view/account', undefined, {}, {} as any); + const missing = await makeDispatcher({ auth }).dispatch('GET', '/ui/view/account', undefined, {}, {} as any); expect(missing.response?.status).toBe(501); expect(missing.response?.body?.error?.message ?? '').toContain('MetadataPlugin'); + + // The same request with no session is the domain floor's answer, and + // the protocol is never asked. + const anonymous = await makeDispatcher({ protocol: { getUiView } }).dispatch('GET', '/ui/view/account/list', undefined, {}, {} as any); + expect(anonymous.response?.status).toBe(401); + expect(anonymous.response?.body?.error?.code).toBe('UNAUTHENTICATED'); + expect(getUiView).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/runtime/src/domains/data-permission-denied-envelope.test.ts b/packages/runtime/src/domains/data-permission-denied-envelope.test.ts index 8c8b6e4f483..bcd3d802d1c 100644 --- a/packages/runtime/src/domains/data-permission-denied-envelope.test.ts +++ b/packages/runtime/src/domains/data-permission-denied-envelope.test.ts @@ -118,9 +118,16 @@ describe('/data PERMISSION_DENIED body — #7450', () => { getObjects: vi.fn().mockReturnValue({}), registry: { getObject: vi.fn().mockReturnValue({ name: 'app_parent_object' }) }, }; + // ADR-0138 D2: `/data` refuses an anonymous caller 401 as its first + // statement, and `dispatch()` re-resolves the caller from the request — + // so the kernel answers a session for it in the shape + // `resolveExecutionContext` reads. Only identity is stubbed; the route, + // the engine's denial and every expectation are unchanged. Anonymity is + // pinned in `domains/domain-floor-anonymous-deny.test.ts`. + const auth = { api: { getSession: async () => ({ user: { id: 'u_test' } }) } }; const kernel = { context: { - getService: (name: string) => (name === 'objectql' ? mockObjectQL : null), + getService: (name: string) => (name === 'objectql' ? mockObjectQL : name === 'auth' ? auth : null), }, } as any; dispatcher = new HttpDispatcher(kernel); @@ -151,6 +158,19 @@ describe('/data PERMISSION_DENIED body — #7450', () => { expect(r.response?.body?.error?.details).toEqual({ object: 'app_parent_object' }); }); + it('an anonymous caller never reaches the engine: the domain floor answers 401 UNAUTHENTICATED first', async () => { + mockObjectQL.update.mockRejectedValue(directDenial('app_parent_object')); + const anonymous = new HttpDispatcher({ context: { getService: (n: string) => (n === 'objectql' ? mockObjectQL : null) } } as any); + + const r = await anonymous.dispatch('PATCH', '/data/app_parent_object/1', { name: 'x' }, {}, { request: {} } as any); + + expect(r.handled).toBe(true); + expect(r.response?.status).toBe(401); + expect(r.response?.body?.error?.code).toBe('UNAUTHENTICATED'); + expect(mockObjectQL.find).not.toHaveBeenCalled(); + expect(mockObjectQL.update).not.toHaveBeenCalled(); + }); + it('never serialises positions or permissionSets', async () => { mockObjectQL.update.mockRejectedValue(directDenial('app_parent_object')); diff --git a/packages/runtime/src/http-dispatcher.permission-denied-user-message.test.ts b/packages/runtime/src/http-dispatcher.permission-denied-user-message.test.ts index bee1794e7c9..5460f055231 100644 --- a/packages/runtime/src/http-dispatcher.permission-denied-user-message.test.ts +++ b/packages/runtime/src/http-dispatcher.permission-denied-user-message.test.ts @@ -155,9 +155,16 @@ describe('[#13623] the dispatcher denial door carries `userMessage`', () => { getObjects: vi.fn().mockReturnValue({}), registry: { getObject: vi.fn().mockReturnValue({ name: 'app_parent_object' }) }, }; + // ADR-0138 D2: `/data` refuses an anonymous caller 401 as its first + // statement, and `dispatch()` re-resolves the caller from the request — + // so the kernel answers a session for it in the shape + // `resolveExecutionContext` reads. Only identity is stubbed; the route, + // the engine's denial and every expectation are unchanged. Anonymity is + // pinned in `domains/domain-floor-anonymous-deny.test.ts`. + const auth = { api: { getSession: async () => ({ user: { id: 'u_test' } }) } }; const kernel = { context: { - getService: (name: string) => (name === 'objectql' ? mockObjectQL : null), + getService: (name: string) => (name === 'objectql' ? mockObjectQL : name === 'auth' ? auth : null), }, } as any; dispatcher = new HttpDispatcher(kernel); diff --git a/packages/runtime/src/http-dispatcher.scoped-url-strip.test.ts b/packages/runtime/src/http-dispatcher.scoped-url-strip.test.ts index 79cf983e1eb..4df04c77369 100644 --- a/packages/runtime/src/http-dispatcher.scoped-url-strip.test.ts +++ b/packages/runtime/src/http-dispatcher.scoped-url-strip.test.ts @@ -63,7 +63,9 @@ function subPathAsTheCatchAllDerivesIt(url: string): string { * A kernel that provides NO services at all. * * Deliberate: it makes "which domain claimed the path" the only variable. A - * claimed `/data` answers 503 SERVICE_UNAVAILABLE (the domain ran and found no + * claimed `/data` answers the domain's own first statement — since ADR-0138 D2 + * the anonymous floor, 401 UNAUTHENTICATED, for this caller with no session + * (it answered 503 SERVICE_UNAVAILABLE before, from the body, which found no * data service); an unclaimed path answers 404 ROUTE_NOT_FOUND. Those two are * the whole signal, and a kernel with services wired would blur them. */ @@ -104,9 +106,12 @@ describe('scoped-URL prefix — the environment-scoped URL reaches a dispatcher expect((await get(`${PREFIX}/health`)).status).toBe(200); // `/data` has no exact/short-circuit answer, so it proves the DOMAIN ran - // rather than merely that some route matched: the domain body is what - // raises 503 on a missing data service. - await expect(get(`${PREFIX}/data/task`)).rejects.toMatchObject({ statusCode: 503 }); + // rather than merely that some route matched: the domain's first + // statement is what answers 401 UNAUTHENTICATED to a caller with no + // session — no dispatcher stage ahead of the registry answers it. + const data = await get(`${PREFIX}/data/task`); + expect(data.status).toBe(401); + expect(data.code).toBe('UNAUTHENTICATED'); }); it('NEGATIVE CONTROL: an unclaimed path answers 404 ROUTE_NOT_FOUND — the shape "matched no domain" takes', async () => { @@ -117,7 +122,9 @@ describe('scoped-URL prefix — the environment-scoped URL reaches a dispatcher it('the environment-scoped URL is stripped and reaches the same domain as the unscoped one', async () => { expect((await get(`${PREFIX}/environments/${ENV_ID}/health`)).status).toBe(200); - await expect(get(`${PREFIX}/environments/${ENV_ID}/data/task`)).rejects.toMatchObject({ statusCode: 503 }); + const data = await get(`${PREFIX}/environments/${ENV_ID}/data/task`); + expect(data.status).toBe(401); + expect(data.code).toBe('UNAUTHENTICATED'); }); it('parses the environment id off the SAME prefix it strips — the two readings must not drift again', async () => { diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 03147e9d66c..e24f546ea63 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -1569,13 +1569,13 @@ describe('HttpDispatcher', () => { const result = await dispatcher.handleData( '/order_item/oi_1', 'GET', {}, { expand: 'order,product', select: 'name,total' }, - { request: {} } + AUTHED_CALLER() ); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); expect(mockProtocol.getData).toHaveBeenCalledWith( - { object: 'order_item', id: 'oi_1', expand: 'order,product', select: 'name,total' } + { object: 'order_item', id: 'oi_1', expand: 'order,product', select: 'name,total', context: AUTHED_CALLER().executionContext } ); }); @@ -1585,12 +1585,12 @@ describe('HttpDispatcher', () => { await dispatcher.handleData( '/task/t1', 'GET', {}, { expand: 'assignee', malicious: 'drop_table', filter: 'hack' }, - { request: {} } + AUTHED_CALLER() ); // Only expand is passed; malicious and filter are dropped expect(mockProtocol.getData).toHaveBeenCalledWith( - { object: 'task', id: 't1', expand: 'assignee' } + { object: 'task', id: 't1', expand: 'assignee', context: AUTHED_CALLER().executionContext } ); }); @@ -1601,7 +1601,7 @@ describe('HttpDispatcher', () => { const result = await dispatcher.handleData( '/task', 'GET', {}, query, - { request: {} } + AUTHED_CALLER() ); expect(result.handled).toBe(true); @@ -1610,7 +1610,7 @@ describe('HttpDispatcher', () => { // two copies of one precedence, disagreeing on three alias pairs. // Folding is owned by the protocol normalizer alone now. expect(mockProtocol.findData).toHaveBeenCalledWith( - { object: 'task', query: { populate: 'assignee,project', top: '10', skip: '0' } } + { object: 'task', query: { populate: 'assignee,project', top: '10', skip: '0' }, context: AUTHED_CALLER().executionContext } ); }); @@ -1618,19 +1618,35 @@ describe('HttpDispatcher', () => { mockProtocol.findData.mockResolvedValue({ object: 'order', records: [], total: 0 }); const query = { expand: 'customer,products' }; - await dispatcher.handleData('/order', 'GET', {}, query, { request: {} }); + await dispatcher.handleData('/order', 'GET', {}, query, AUTHED_CALLER()); expect(mockProtocol.findData).toHaveBeenCalledWith( - { object: 'order', query: { expand: 'customer,products' } } + { object: 'order', query: { expand: 'customer,products' }, context: AUTHED_CALLER().executionContext } ); }); it('should return error if object name is missing', async () => { - const result = await dispatcher.handleData('/', 'GET', {}, {}, { request: {} }); + const result = await dispatcher.handleData('/', 'GET', {}, {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(400); }); + // ADR-0138 D2: the anonymous decision is the domain's FIRST statement, + // so every case above signs its caller in — they are about which + // protocol verb a face reaches and with which arguments, and were only + // ever anonymous incidentally. The anonymous answer is pinned here, and + // per face in `domains/domain-floor-anonymous-deny.test.ts`. + it('refuses an anonymous caller 401 UNAUTHENTICATED before any face, the 400 included', async () => { + for (const [path, method] of [['/order_item/oi_1', 'GET'], ['/task', 'GET'], ['/', 'GET'], ['/task/query', 'POST']] as const) { + const result = await dispatcher.handleData(path, method, {}, {}, { request: {} }); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(401); + expect(result.response?.body?.error?.code).toBe('UNAUTHENTICATED'); + } + expect(mockProtocol.getData).not.toHaveBeenCalled(); + expect(mockProtocol.findData).not.toHaveBeenCalled(); + }); + it('should handle POST /data/:object/query with body containing expand', async () => { mockProtocol.findData.mockResolvedValue({ object: 'task', records: [] }); @@ -1638,11 +1654,11 @@ describe('HttpDispatcher', () => { '/task/query', 'POST', { filter: { status: 'active' }, populate: ['assignee'] }, {}, - { request: {} } + AUTHED_CALLER() ); expect(mockProtocol.findData).toHaveBeenCalledWith( - { object: 'task', query: { filter: { status: 'active' }, populate: ['assignee'] } } + { object: 'task', query: { filter: { status: 'active' }, populate: ['assignee'] }, context: AUTHED_CALLER().executionContext } ); }); }); @@ -3614,10 +3630,17 @@ describe('HttpDispatcher', () => { expect(info.services.ui.provider).toBe('metadata-protocol'); // The advertised route really answers — same predicate, same fact. - const served = await dispatcher.handleUi('/view/account', {}, { request: {} }); + // Signed in: since ADR-0138 D2 the domain refuses an anonymous + // caller 401 as its first statement — asserted beside it. + const served = await dispatcher.handleUi('/view/account', {}, AUTHED_CALLER()); expect(served.handled).toBe(true); expect(served.response?.status).toBe(200); expect(protocol.getUiView).toHaveBeenCalledWith({ object: 'account', type: 'list' }); + + const anonymous = await dispatcher.handleUi('/view/account', {}, { request: {} }); + expect(anonymous.response?.status).toBe(401); + expect(anonymous.response?.body?.error?.code).toBe('UNAUTHENTICATED'); + expect(protocol.getUiView).toHaveBeenCalledTimes(1); }); it('does not advertise /ui without a protocol, and names the actual remedy', async () => { @@ -3642,7 +3665,7 @@ describe('HttpDispatcher', () => { expect(info.routes.ui).toBeUndefined(); expect(info.services.ui.enabled).toBe(false); - const served = await dispatcher.handleUi('/view/account', {}, { request: {} }); + const served = await dispatcher.handleUi('/view/account', {}, AUTHED_CALLER()); expect(served.handled).toBe(true); expect(served.response?.status).toBe(501); }); From 2e0618f829337dcae8480c65f3db0acefac6e4f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 01:01:08 +0000 Subject: [PATCH 3/4] test(dogfood): record the /ui and /data domain floors in the authorization conformance matrix (ADR-0138 D2) Two floor rows with their gate pins, the two dispatcher-domain keys classified (baseline 26 -> 24), and the probe census re-measured (25 probes over 20 files, 33 covers keys). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015sSBzUJfLxTo3ZZ68LLfCG --- .../dogfood/test/authz-conformance.matrix.ts | 36 +++++++++---- .../qa/dogfood/test/authz-conformance.test.ts | 22 ++++++++ .../test/authz-ledger-population.baseline.ts | 17 ++++--- .../test/authz-probe-blind-spot.census.ts | 51 +++++++++++++++++-- .../test/authz-probe-blind-spot.test.ts | 13 ++--- 5 files changed, 111 insertions(+), 28 deletions(-) diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 7b33f2e0c79..85d0aa4b16a 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()`: 23 probes over 18 named source files) — a new REST +// tripwires (`discover()`: 25 probes over 20 named source files) — a new REST // route FAMILY or dispatcher DOMAIN is UNCLASSIFIED, a deleted named guard is // STALE, and either breaks CI. // @@ -67,11 +67,12 @@ // The ledgers supply the POPULATION; the classification stays a reviewed row // here. // -// 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 +// Of the 41 ledger keys, 17 are classified by rows below (re-derived when the +// `/ui` and `/data` domains arrived classified under ADR-0138 D2's second +// slice, after the `forms` family and the `/notifications`, `/keys` and +// `/share-links` domains did under its first, the `/webhooks/redeliver` domain // did, #22755, `/approvals/act` did, #22576, and `/i18n` left the baseline, -// #22432); the other 26 are enumerated one by one, +// #22432); the other 24 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, @@ -79,7 +80,7 @@ // 2026-08-31 those surfaces minted no key at all, so nothing about them was // visible in either direction. // -// Each of the 20 probes DECLARES its instrument kind +// Each of the 25 probes DECLARES its instrument kind // (ROUTE_ENUMERATION / GATE_PIN / TRIPWIRE — see the companion test), and a // non-tripwire probe that mints ZERO keys fails as a DEAD PROBE. That closes a // blind-spot mechanism neither UNCLASSIFIED nor STALE can reach: both are @@ -101,16 +102,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: -// 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: +// 49 of 67 carry no `covers` key at all (18 rows, 33 keys, every one an +// HTTP/transport pin), and 43 of the file's 60 `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 — 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 +// 33 `covers` keys that DO exist, 12 are GATE pins tied to the enforcement call // itself, not merely a function name — delete `shouldDenyAnonymous` from // `/actions`, `/automation`, `/packages`, `/analytics`, `/i18n`, -// `/notifications`, `/keys` or `/share-links`, or drop the MCP +// `/notifications`, `/keys`, `/share-links`, `/ui` or `/data`, 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 @@ -331,6 +332,21 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ 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).' }, + // ── ADR-0138 D2 (card E2), the second slice — the two dispatcher domains + // that made NO anonymous decision of their own: an unauthenticated caller + // was answered by whichever face it named. Same shape as the three above: + // the decision is the handler's first statement, a gate pin goes STALE when + // it is deleted, and the domain's ledger key is classified beside it. No D2 + // door class runs through either domain (class 4 reaches the CRUD pipeline + // from the transport's fallback seam, never through `/data`). + { id: 'anonymous-deny-ui', summary: 'anonymous-deny on the rendered-view surface of the dispatcher', state: 'enforced', + enforcement: 'runtime/domains/ui.ts handleUiRequest — shouldDenyAnonymous DOMAIN-WIDE as the handler\'s FIRST statement, ahead of the path parse, the type-parameter check and the protocol probe; 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: ['ui:domains/ui.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/ui'], + note: 'The DISPATCHER domain only: where @objectstack/rest is mounted, its guarded `ui` family serves the same paths and answers an anonymous caller 401, so the booted showcase never reaches this handler and its pins are unit pins. Before the move this domain made no anonymous decision of its own. Unit-proven in runtime/domains/domain-floor-anonymous-deny.test.ts (every spelling of the view face and every unserved path refused 401 before any service is resolved, both anonymous shapes; no protocol and a repeated type still 401; the signed-in control unchanged: served, 501 without a protocol, 400 for a repeated type); the registry route to the domain through dispatch() is pinned anonymous 401 and signed-in 200 in runtime/domain-handler-registry.test.ts.' }, + { id: 'anonymous-deny-data', summary: 'anonymous-deny on the CRUD data surface of the dispatcher', state: 'enforced', + enforcement: 'runtime/domains/data.ts handleDataRequest — shouldDenyAnonymous DOMAIN-WIDE as the handler\'s FIRST statement, ahead of the path parse, the multi-tenant environment check and every read and write face; every face, and every path or verb the domain does not serve, answers the dispatcher-wrapper 401 UNAUTHENTICATED (a CORS preflight keeps its pass), and a signed-in caller reaches the CRUD pipeline exactly as before', + covers: ['data:domains/data.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/data'], + note: 'The DISPATCHER domain only: where @objectstack/rest is mounted, its `crud` family serves the same paths behind its own per-route check, so the booted showcase never reaches this handler and its pins are unit pins. Before the move this domain made no anonymous decision of its own, so an anonymous caller was answered by the face it named: the CRUD gate\'s refusal of the guest envelope (403), a validation answer (400) or the environment check (428). D2 class 4 is unaffected: a declared object_operation endpoint reaches callData from the fallback seam, not through this handler, and the anonymous door sweep keeps proving it. Unit-proven in runtime/domains/domain-floor-anonymous-deny.test.ts (every read and write face and every unserved path or verb refused 401 before any service is resolved, both anonymous shapes; the 400 and 428 answers behind the floor; the signed-in control unchanged) and through dispatch() in runtime/domains/data-permission-denied-envelope.test.ts (anonymous 401 before the engine is asked).' }, // #22576 — the `/approvals/act` dispatcher domain (segment 2 of ruling A on // #22438, "token-only, aligned with self-hosted"). Unlike every diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index 883dff3202a..fac0274c31c 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -283,6 +283,22 @@ const PROBES: readonly Probe[] = [ re: /shouldDenyAnonymous\s*\(/g, key: () => 'share-links:domains/share-links.ts:anonymous-gate', }, + // ADR-0138 D2 (card E2), the second slice — the two domains that made no + // anonymous decision of their own until it became their first statement. + // Same GATE-pin shape: delete a floor and its key vanishes → the covering + // `anonymous-deny-ui` / `anonymous-deny-data` row goes STALE → red CI. + { + kind: 'GATE_PIN', + file: 'packages/runtime/src/domains/ui.ts', + re: /shouldDenyAnonymous\s*\(/g, + key: () => 'ui:domains/ui.ts:anonymous-gate', + }, + { + kind: 'GATE_PIN', + file: 'packages/runtime/src/domains/data.ts', + re: /shouldDenyAnonymous\s*\(/g, + key: () => 'data:domains/data.ts:anonymous-gate', + }, // ── a probe whose POPULATION WAS DELETED, re-declared for what it is ──── // @@ -1031,6 +1047,8 @@ describe('the ledger-sourced population and its baseline bite', () => { // [#22576] classified by `approval-action-token`. 'dispatcher-domain:route-ledger.ts:/approvals/act', 'dispatcher-domain:route-ledger.ts:/automation', + // [ADR-0138 D2, second slice] classified by `anonymous-deny-data`. + 'dispatcher-domain:route-ledger.ts:/data', // [#22432] classified by `anonymous-deny-i18n`. 'dispatcher-domain:route-ledger.ts:/i18n', // [ADR-0138 D2] classified by `anonymous-deny-keys`. @@ -1042,6 +1060,8 @@ describe('the ledger-sourced population and its baseline bite', () => { 'dispatcher-domain:route-ledger.ts:/packages', // [ADR-0138 D2] classified by `anonymous-deny-share-links`. 'dispatcher-domain:route-ledger.ts:/share-links', + // [ADR-0138 D2, second slice] classified by `anonymous-deny-ui`. + 'dispatcher-domain:route-ledger.ts:/ui', // [#22755] classified by `webhook-redeliver-session`. 'dispatcher-domain:route-ledger.ts:/webhooks/redeliver', // [ADR-0138 D2] classified by `anonymous-door-public-form` (class 1). @@ -1289,6 +1309,8 @@ const DOMAIN_FLOOR_GATE_KEYS = [ 'notifications:domains/notifications.ts:anonymous-gate', 'keys:domains/keys.ts:anonymous-gate', 'share-links:domains/share-links.ts:anonymous-gate', + 'ui:domains/ui.ts:anonymous-gate', + 'data:domains/data.ts:anonymous-gate', ] as const; /** Every row recording a door class carries this id prefix, and no other row may. */ diff --git a/packages/qa/dogfood/test/authz-ledger-population.baseline.ts b/packages/qa/dogfood/test/authz-ledger-population.baseline.ts index 1bcf93b5843..99dc1d3af3d 100644 --- a/packages/qa/dogfood/test/authz-ledger-population.baseline.ts +++ b/packages/qa/dogfood/test/authz-ledger-population.baseline.ts @@ -96,16 +96,16 @@ 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), and `/notifications`, + // `/packages`, `/mcp`, `/analytics`, `/i18n` (#22432), `/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. + // moved to its first statement), and `/ui` and `/data` (ADR-0138 D2's second + // slice: each domain's anonymous decision became its first statement). + // `/approvals/act` and `/webhooks/redeliver` were never enumerated here: they + // arrived classified. The other 9 domains are here. 'dispatcher-domain:route-ledger.ts:/.well-known/objectstack', 'dispatcher-domain:route-ledger.ts:/ai', 'dispatcher-domain:route-ledger.ts:/apps', 'dispatcher-domain:route-ledger.ts:/auth', - 'dispatcher-domain:route-ledger.ts:/data', 'dispatcher-domain:route-ledger.ts:/discovery', 'dispatcher-domain:route-ledger.ts:/health', // ⚠️ Separate from `/mcp`, deliberately. The `/mcp` key is classified by @@ -115,7 +115,6 @@ export const LEDGER_POPULATION_BASELINE: readonly string[] = [ 'dispatcher-domain:route-ledger.ts:/mcp/skill', 'dispatcher-domain:route-ledger.ts:/ready', 'dispatcher-domain:route-ledger.ts:/security', - 'dispatcher-domain:route-ledger.ts:/ui', ]; /** @@ -138,5 +137,9 @@ export const LEDGER_POPULATION_BASELINE: readonly string[] = [ * `/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. + * 24 at 2026-10-14 (ADR-0138 card E2, second slice): + * `dispatcher-domain:route-ledger.ts:/ui` and `/data` left classified by + * `anonymous-deny-ui` and `anonymous-deny-data`, each with its domain gate pin, + * once each domain's anonymous decision became its first statement. */ -export const LEDGER_POPULATION_BASELINE_MAX = 26; +export const LEDGER_POPULATION_BASELINE_MAX = 24; 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 67a01867d3c..ea70ff2232a 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -308,7 +308,12 @@ export interface ProbeTableReading { // 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 }; +// [ADR-0138 E2, second slice] 23 / 18 / 29 -> 25 / 20 / 33: the `/ui` and +// `/data` domain gates joined the table as GATE_PINs on their two domain +// files, once each domain's anonymous decision became its first statement; +// `anonymous-deny-ui` and `anonymous-deny-data` cover each gate key plus its +// dispatcher-domain key — two keys that left the baseline (26 -> 24). +export const PROBE_TABLE: ProbeTableReading = { entries: 25, files: 20, keys: 33 }; /** * The probe count `authz-conformance.matrix.ts`'s header states. @@ -333,8 +338,10 @@ export const PROBE_TABLE: ProbeTableReading = { entries: 23, files: 18, keys: 29 * sentence moved together. * [ADR-0138 E2] 20 -> 23, the same way: the three domain gate pins and the * header sentence moved together. + * [ADR-0138 E2, second slice] 23 -> 25, the same way: the `/ui` and `/data` + * gate pins and the header sentence moved together. */ -export const MATRIX_HEADER_PROBE_CLAIM = 23; +export const MATRIX_HEADER_PROBE_CLAIM = 25; export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // ── the two LEDGER files: the population source since 2026-08-31 ─────── @@ -478,15 +485,19 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // `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. + // [ADR-0138 E2, second slice] `domains/ui.ts` and `domains/data.ts` are now + // named by a probe too (their domain floors' gate pins), and their two keys + // are the thirteenth and fourteenth classified: 23 domains, 9 still in the + // baseline, 6 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 8 files no probe names. 23 domains; 12 classified ' + + 'ledger domains today, including the 6 files no probe names. 23 domains; 14 classified ' + '(/meta, /actions, /automation, /packages, /mcp, /analytics, /i18n, /approvals/act, ' + - '/webhooks/redeliver, /notifications, /keys, /share-links), 11 in the shrink-only baseline.', + '/webhooks/redeliver, /notifications, /keys, /share-links, /ui, /data), 9 in the shrink-only baseline.', }, { file: 'packages/rest/src/rest-server.ts', @@ -769,6 +780,34 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ '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.', }, + { + // [ADR-0138 E2, second slice] The two domains that made no anonymous + // decision of their own until it became the handler's first statement; + // each row has the actions.ts shape. The handler-name control counts the + // declaration and the DomainRoute call site, twice in each file. + file: 'packages/runtime/src/domains/ui.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, handleUiRequest: 2 }, + note: 'Same shape as domains/actions.ts.', + }, + { + file: 'packages/runtime/src/domains/data.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, handleDataRequest: 2 }, + note: 'Same shape as domains/actions.ts.', + }, { file: 'packages/runtime/src/domains/mcp.ts', kinds: ['GATE_PIN'], @@ -1113,7 +1152,7 @@ export function deriveProbeFileCensus(): { }); } - // ── the eight runtime domain files whose floor is a GATE_PIN ───────────── + // ── the ten 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 @@ -1129,6 +1168,8 @@ export function deriveProbeFileCensus(): { ['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'], + ['packages/runtime/src/domains/ui.ts', 'shouldDenyAnonymous(', 'handleUiRequest'], + ['packages/runtime/src/domains/data.ts', 'shouldDenyAnonymous(', 'handleDataRequest'], ]; 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 2a0eab4726d..17f6b82c08d 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 — 29 `covers` keys, no more', () => { - // The matrix's `covers` keys number 29 today: the 9 probe-minted keys this + it('every classified key is accounted for — 33 `covers` keys, no more', () => { + // The matrix's `covers` keys number 33 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 @@ -55,15 +55,16 @@ describe('authz probe blind-spot census (#13260)', () => { // `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 + // by the D2 class 1 row, plus — under its second slice — the `/ui` and + // `/data` domains' gate-pin keys and dispatcher-domain keys. 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; 15 - // are classified here and 26 are enumerated in the shrink-only baseline, + // ⚠️ This is NOT the size of the population. The ledgers mint 41 keys; 17 + // are classified here and 24 are enumerated in the shrink-only baseline, // which `authz-conformance.test.ts` holds to its own four rules. - expect(PROBE_TABLE.keys).toBe(29); + expect(PROBE_TABLE.keys).toBe(33); }); it.each(PROBE_FILE_CENSUS.map((r) => [r.file, r] as const))( From 87bedeb1cfee6c1fa9f81db6eef44b970b1900e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 01:03:06 +0000 Subject: [PATCH 4/4] =?UTF-8?q?chore(changeset):=20the=20/ui=20and=20/data?= =?UTF-8?q?=20domain=20floors=20(runtime=20minor,=20Clause-=E2=91=A1=20no?= =?UTF-8?q?=20narrowing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015sSBzUJfLxTo3ZZ68LLfCG --- .changeset/23225-ui-data-domain-floor.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changeset/23225-ui-data-domain-floor.md diff --git a/.changeset/23225-ui-data-domain-floor.md b/.changeset/23225-ui-data-domain-floor.md new file mode 100644 index 00000000000..71bbf1a403e --- /dev/null +++ b/.changeset/23225-ui-data-domain-floor.md @@ -0,0 +1,20 @@ +--- +'@objectstack/runtime': minor +--- + +fix(runtime)!: the `/ui` and `/data` dispatcher domains decide an anonymous request once, as each domain's first statement, so an unauthenticated caller is answered 401 on every face they serve + +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". The rendered-view domain `/ui` and the CRUD and query domain `/data` made no anonymous decision of their own: an unauthenticated caller was answered by whichever face it named. Each now opens with the shared `shouldDenyAnonymous` decision, before the path is parsed and before any service is resolved, so a face added to either later is refused to an anonymous caller from the day it lands. No door class D2 lists runs through either domain. + +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, wherever these dispatcher domains serve the request. Where `@objectstack/rest` is mounted it serves the same paths itself and already answered 401, so the standalone server's answers do not move. + +- `/data`: `401 UNAUTHENTICATED` on every read and write face, and on every path or verb the domain does not serve. The answer used to come from the face: the CRUD gate's `403 PERMISSION_DENIED` for the guest, a `400` for a request naming no object, a `428` for an unresolved environment on a multi-tenant host. A CORS preflight keeps its pass. +- `/ui`: `401 UNAUTHENTICATED` on every face, and on every path the domain does not serve. + +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. Nothing a signed-in caller is served changes. The decision is asked about the caller's user id, as the `/notifications` and `/keys` floors ask it: `dispatch()` resolves identity for every request, so no caller on the wire is a system context, and an in-process call of either handler that passes a system context with no user id is refused too.