From 7d31b504487761c600636bb61171953f90473b08 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:41:28 +0000 Subject: [PATCH 1/9] fix(rest): the API-description endpoints refuse an anonymous caller RestServer.registerOpenApiEndpoints now opens both handlers (the document and its viewer) with the shared anonymous-deny floor, before any work, so an anonymous caller gets the same 401 UNAUTHENTICATED body the /data and /meta routes answer. A signed-in caller is served as before. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- packages/rest/src/openapi-builtin-paths.ts | 10 ++-- packages/rest/src/rest-server.ts | 53 ++++++++++++++++------ 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/packages/rest/src/openapi-builtin-paths.ts b/packages/rest/src/openapi-builtin-paths.ts index 0ed4213717d..ea9318675fd 100644 --- a/packages/rest/src/openapi-builtin-paths.ts +++ b/packages/rest/src/openapi-builtin-paths.ts @@ -108,10 +108,12 @@ * One consequence worth stating: per-operation `security` is emitted ONLY as * `[]`, for routes whose registration tags them `public` (the anonymous form * runner). Every other operation says nothing and inherits the document-level - * requirement. That under-claims for the handful of routes that answer - * anonymously (`/discovery`, `/openapi.json`, `/docs`) — deliberately: the - * registration carries no auth fact for them, and "credentials not needed" is - * the one direction where a wrong claim leaks data. + * requirement. That under-claims for the discovery routes, which answer + * anonymously — deliberately: the registration carries no auth fact for them, + * and "credentials not needed" is the one direction where a wrong claim leaks + * data. The two API-description routes (`/openapi.json`, `/docs`) refuse an + * anonymous caller (#22430), so the inherited requirement is simply true of + * them. */ /** The slice of a registered route this module reads. */ diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 89fae0b5e5f..32bb64d053e 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -5095,8 +5095,25 @@ export class RestServer { private registerOpenApiEndpoints(basePath: string): void { const isScoped = basePath.includes('/environments/:environmentId'); + // [#22430] Both API-description endpoints — the document and its + // viewer — refuse an anonymous caller with the shared anonymous-deny + // 401, the same body the `/data` and `/meta` routes answer (ADR-0056 D2 + // is default-deny, and no ADR-0138 D2 door class names these two). The + // refusal is each handler's FIRST act: before the bundled artifact is + // loaded and before any protocol is resolved, so a refused request + // costs no work and its answer does not even say whether a document is + // bundled here. A signed-in caller is served exactly as before, under + // the same ADR-0069 auth-policy gate every other protected route + // applies. The viewer page fetches the document from the browser, and + // that request carries the browser's session, so a signed-in browser is + // served on both endpoints. + const openApiHandler = async (req: any, res: any) => { try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + const spec = await this.loadOpenApiSpec(); if (!spec) { res.status?.(503); @@ -5149,7 +5166,6 @@ export class RestServer { // must not silently blank the other. let protocol: RestProtocol | undefined; try { - const environmentId = isScoped ? req.params?.environmentId : undefined; protocol = await this.resolveProtocol(environmentId, req); } catch { // Enrichment is best-effort — never fail the spec serve. @@ -5304,14 +5320,19 @@ export class RestServer { method: 'GET', path: `${basePath}/docs`, handler: async (req: any, res: any) => { - // Resolve the openapi.json URL relative to the current - // request so the docs page works for any host / scoped - // base path (e.g. /api/v1 vs /api/v1/environments/abc). - const reqPath: string = req.path || req.url || `${basePath}/docs`; - // Strip the trailing /docs to get the API base. - const apiBase = reqPath.replace(/\/docs\/?$/, ''); - const specUrl = `${apiBase}/openapi.json`; - const html = ` + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + + // Resolve the openapi.json URL relative to the current + // request so the docs page works for any host / scoped + // base path (e.g. /api/v1 vs /api/v1/environments/abc). + const reqPath: string = req.path || req.url || `${basePath}/docs`; + // Strip the trailing /docs to get the API base. + const apiBase = reqPath.replace(/\/docs\/?$/, ''); + const specUrl = `${apiBase}/openapi.json`; + const html = ` @@ -5323,10 +5344,16 @@ export class RestServer { `; - if (res.setHeader) res.setHeader('content-type', 'text/html; charset=utf-8'); - if (res.send) res.send(html); - else if (res.body) res.body = html; - else res.json?.(html); + if (res.setHeader) res.setHeader('content-type', 'text/html; charset=utf-8'); + if (res.send) res.send(html); + else if (res.body) res.body = html; + else res.json?.(html); + } catch (error: any) { + // Only the identity read above can throw here (a + // permission-store outage), which keeps its declared answer. + logError('[REST] docs viewer error:', error); + sendThrownError(res, error); + } }, metadata: { summary: 'Interactive API docs (Scalar viewer)', From 9e998a7d42e676bcda0075f97ea4bf5c7fffced8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:43:33 +0000 Subject: [PATCH 2/9] test(rest): pin the API-description endpoints' anonymous refusal and signed-in service Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- ...est-api-description-anonymous-deny.test.ts | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 packages/rest/src/rest-api-description-anonymous-deny.test.ts diff --git a/packages/rest/src/rest-api-description-anonymous-deny.test.ts b/packages/rest/src/rest-api-description-anonymous-deny.test.ts new file mode 100644 index 00000000000..04cb7c78f01 --- /dev/null +++ b/packages/rest/src/rest-api-description-anonymous-deny.test.ts @@ -0,0 +1,209 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22430] The API-description endpoints — the document (`GET {base}/openapi.json`) + * and its viewer (`GET {base}/docs`) — refuse an anonymous caller. + * + * `RestServer.registerOpenApiEndpoints` registers both endpoints on every base + * the server mounts: the unscoped `/api/v1` and, under project scoping, its + * `/environments/:environmentId` twin. Both handlers now open with the shared + * anonymous-deny floor, so every endpoint × base cell is driven here, in both + * directions: + * + * - ANONYMOUS — `401` with the ADR-0112 `code` (`UNAUTHENTICATED`), the body + * byte-identical to the one the `/data` routes answer on the same server, + * nothing of the document or the viewer page in it, and no work done: the + * bundled artifact is never loaded and the protocol is never asked. + * - SIGNED-IN — served exactly as before: the document (an OpenAPI 3.1 body), + * and the viewer page pointed at its sibling document on the same base. + * + * The anonymous leg drives the REAL identity chain: the server holds an auth + * service whose session read answers "no session", which is what a request + * without a cookie meets in production. The signed-in leg supplies the + * resolved context directly — identity resolution is not the subject here, and + * the booted-showcase proof (`showcase-anonymous-deny-surfaces.dogfood.test.ts`) + * drives both legs through a real session over HTTP. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + ANONYMOUS_DENY_BODY, + ANONYMOUS_DENY_CODE, + ANONYMOUS_DENY_STATUS, + AuthzStoreUnavailableError, + AUTHZ_STORE_UNAVAILABLE_CODE, + AUTHZ_STORE_UNAVAILABLE_STATUS, +} from '@objectstack/core'; +import { RestServer } from './rest-server'; + +const UNSCOPED = '/api/v1'; +const SCOPED = '/api/v1/environments/:environmentId'; +const BASES = [UNSCOPED, SCOPED] as const; +const ENV = 'env_1'; + +const ENDPOINTS = [ + { name: 'the document', tail: '/openapi.json' }, + { name: 'the viewer', tail: '/docs' }, +] as const; + +/** The base as a caller spells it — the scoped pattern with a real id in it. */ +const wireBase = (base: string) => base.replace(':environmentId', ENV); + +function makeHttpServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn(), close: vi.fn(), + } as any; +} + +/** An auth service whose session read answers "no session" — a request with no cookie. */ +const noSessionAuth = async () => ({ api: { getSession: vi.fn(async () => null) } }); + +/** + * A server with BOTH bases mounted, its whole route table registered (the + * document's built-in section is that table), and the two pieces of work a + * refusal must not reach observable: the artifact load and the protocol read. + */ +function makeRest() { + const protocol: any = { + getMetaItems: vi.fn(async ({ type }: { type: string }) => ({ type, items: [] })), + findData: vi.fn(async () => ({ records: [], total: 0 })), + }; + const rest = new RestServer( + makeHttpServer(), + protocol, + { api: { version: 'v1', enableProjectScoping: true, projectResolution: 'auto' } } as any, + undefined, undefined, undefined, + noSessionAuth, + ); + rest.registerRoutes(); + const loadSpec = vi.spyOn(rest as any, 'loadOpenApiSpec'); + return { rest, protocol, loadSpec }; +} + +interface Observed { + status: number; + body: unknown; + html: string | undefined; + contentType: string | undefined; +} + +async function drive(rest: RestServer, path: string, environmentId?: string): Promise { + const entry = (rest as any).routeManager.get('GET', path); + expect(entry, `GET ${path} must be mounted for this case to mean anything`).toBeDefined(); + const observed: Observed = { status: 200, body: undefined, html: undefined, contentType: undefined }; + const res: any = { + status: (c: number) => { observed.status = c; return res; }, + json: (b: unknown) => { observed.body = b; return res; }, + send: (html: string) => { observed.html = html; return res; }, + setHeader: (k: string, v: string) => { if (k.toLowerCase() === 'content-type') observed.contentType = v; return res; }, + header: () => res, + }; + const wirePath = path.replace(':environmentId', ENV); + await entry.handler({ + method: 'GET', + path: wirePath, + url: wirePath, + params: environmentId ? { environmentId } : {}, + query: {}, + headers: { host: 'example.test' }, + }, res); + return observed; +} + +const envOf = (base: string) => (base === SCOPED ? ENV : undefined); + +describe('[#22430] the API-description endpoints refuse an anonymous caller', () => { + for (const base of BASES) { + for (const ep of ENDPOINTS) { + it(`${ep.name} on ${base} answers 401 UNAUTHENTICATED, serves nothing and does no work`, async () => { + const { rest, protocol, loadSpec } = makeRest(); + const r = await drive(rest, `${base}${ep.tail}`, envOf(base)); + + // The ADR-0112 envelope: status and code. + expect(r.status).toBe(ANONYMOUS_DENY_STATUS); + expect((r.body as any)?.code).toBe(ANONYMOUS_DENY_CODE); + // The refusal body is the shared one, whole — not a lookalike. + expect(r.body).toEqual(ANONYMOUS_DENY_BODY); + // Nothing served: no document keys, no viewer page. + expect(r.body).not.toHaveProperty('openapi'); + expect(r.body).not.toHaveProperty('paths'); + expect(r.html).toBeUndefined(); + expect(r.contentType).toBeUndefined(); + // And no work: the refusal precedes the artifact and the protocol. + expect(loadSpec).not.toHaveBeenCalled(); + expect(protocol.getMetaItems).not.toHaveBeenCalled(); + }); + } + } + + it('the refusal body is byte-identical to the anonymous 401 the `/data` routes answer on the same server', async () => { + const { rest } = makeRest(); + const document = await drive(rest, `${UNSCOPED}/openapi.json`); + const viewer = await drive(rest, `${UNSCOPED}/docs`); + const data = await drive(rest, `${UNSCOPED}/data/:object`); + // CONTROL: the `/data` route refuses the same anonymous request here, so + // the comparison is between two refusals and not against a stuck value. + expect(data.status).toBe(ANONYMOUS_DENY_STATUS); + expect(JSON.stringify(document.body)).toBe(JSON.stringify(data.body)); + expect(JSON.stringify(viewer.body)).toBe(JSON.stringify(data.body)); + }); +}); + +describe('[#22430] a signed-in caller is served as before', () => { + function signedIn() { + const made = makeRest(); + (made.rest as any).resolveExecCtx = vi.fn(async () => ({ userId: 'u_docs_reader' })); + return made; + } + + for (const base of BASES) { + it(`the document on ${base} is the OpenAPI 3.1 body`, async () => { + const { rest, protocol } = signedIn(); + const r = await drive(rest, `${base}/openapi.json`, envOf(base)); + expect(r.status).toBe(200); + expect((r.body as any)?.openapi).toBe('3.1.0'); + expect(Object.keys((r.body as any)?.paths ?? {}).length).toBeGreaterThan(0); + // The enrichment ran: the protocol was asked for the object model. + expect(protocol.getMetaItems).toHaveBeenCalledWith({ type: 'object' }); + }); + + it(`the viewer on ${base} is the HTML page pointed at its sibling document`, async () => { + const { rest } = signedIn(); + const r = await drive(rest, `${base}/docs`, envOf(base)); + expect(r.status).toBe(200); + expect(r.contentType).toBe('text/html; charset=utf-8'); + expect(r.html).toContain(`data-url="${wireBase(base)}/openapi.json"`); + }); + } +}); + +describe('[#22430] the floor is the shared one, not a bare anonymous check', () => { + it('a session held by the ADR-0069 auth-policy gate is refused with that gate\'s 403, on both endpoints', async () => { + const { rest, loadSpec } = makeRest(); + (rest as any).resolveExecCtx = vi.fn(async () => ({ + userId: 'u_gated', + authGate: { code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' }, + })); + for (const ep of ENDPOINTS) { + const r = await drive(rest, `${UNSCOPED}${ep.tail}`); + expect(r.status, ep.name).toBe(403); + expect((r.body as any)?.error?.code, ep.name).toBe('PASSWORD_EXPIRED'); + expect(r.html, ep.name).toBeUndefined(); + } + expect(loadSpec).not.toHaveBeenCalled(); + }); + + it('a permission-store outage keeps its declared 503 on both endpoints — never a 401, never a 200', async () => { + const { rest } = makeRest(); + (rest as any).resolveExecCtx = vi.fn(async () => { + throw new AuthzStoreUnavailableError('sys_user_permission_set'); + }); + for (const ep of ENDPOINTS) { + const r = await drive(rest, `${UNSCOPED}${ep.tail}`); + expect(r.status, ep.name).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS); + expect(JSON.stringify(r.body), ep.name).toContain(AUTHZ_STORE_UNAVAILABLE_CODE); + expect(r.html, ep.name).toBeUndefined(); + } + }); +}); From aad3f25b2589bfcc6195e06e6367150ff93a65c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:52:59 +0000 Subject: [PATCH 3/9] test(rest): read the API description as a signed-in caller; count the two new bare identity sites Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- .../direct-mount-base-follows-apipath.test.ts | 11 +++++-- .../src/direct-mount-introspection.test.ts | 9 ++++- .../rest/src/execctx-consumer-census.test.ts | 33 ++++++++++++------- .../src/rest-openapi-info-overlay.test.ts | 3 ++ packages/rest/src/rest-openapi-route.test.ts | 4 +++ 5 files changed, 45 insertions(+), 15 deletions(-) diff --git a/packages/rest/src/direct-mount-base-follows-apipath.test.ts b/packages/rest/src/direct-mount-base-follows-apipath.test.ts index 6faa6336268..858a25ba076 100644 --- a/packages/rest/src/direct-mount-base-follows-apipath.test.ts +++ b/packages/rest/src/direct-mount-base-follows-apipath.test.ts @@ -156,8 +156,15 @@ function mountedKeys(table: Map): string[] { async function serveOpenApi(table: Map, base: string) { const entry = resolveRoute(table, 'GET', `${base}/openapi.json`); expect(entry, `GET ${base}/openapi.json must be mounted for this pin to mean anything`).toBeDefined(); - const { body } = await drive(entry!, { path: `${base}/openapi.json` }); - return body; + // [#22430] The document refuses an anonymous caller, so it is read as a + // signed-in one — the reader whose document this file is about. + const signedIn = vi.spyOn(RestServer.prototype as any, 'resolveExecCtx').mockResolvedValue({ userId: 'u_docs_reader' }); + try { + const { body } = await drive(entry!, { path: `${base}/openapi.json` }); + return body; + } finally { + signedIn.mockRestore(); + } } async function readDiscovery(table: Map, base: string) { diff --git a/packages/rest/src/direct-mount-introspection.test.ts b/packages/rest/src/direct-mount-introspection.test.ts index 7fb0bee2327..3f62be2e21e 100644 --- a/packages/rest/src/direct-mount-introspection.test.ts +++ b/packages/rest/src/direct-mount-introspection.test.ts @@ -177,7 +177,14 @@ async function serveOpenApi(server: ReturnType, base = header: () => res, send: () => {}, }; - await (call as any)[1]({ headers: { host: 'example.test' }, params: {}, query: {}, path: `${base}/openapi.json` }, res); + // [#22430] The document refuses an anonymous caller, so it is read as a + // signed-in one — the reader whose document this file is about. + const signedIn = vi.spyOn(RestServer.prototype as any, 'resolveExecCtx').mockResolvedValue({ userId: 'u_docs_reader' }); + try { + await (call as any)[1]({ headers: { host: 'example.test' }, params: {}, query: {}, path: `${base}/openapi.json` }, res); + } finally { + signedIn.mockRestore(); + } return body; } diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index f16a67a06d3..dc3de714377 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -319,7 +319,16 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t // --------------------------------------------------------------------------- describe('[#13160] §2 the consumer surface, counted from the tree', () => { - it('66 invocation sites, 90 mentions — the thread\'s two control numbers hold', () => { + it('68 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => { + // [#22430] 66 → 68 sites / 90 → 92 mentions — two NEW consumers, both + // BARE. The API-description endpoints (`registerOpenApiEndpoints`: the + // document and its viewer) resolved no identity at all and served an + // anonymous caller; each handler now opens with a bare site and the + // shared anonymous floor on the very next line, the family §2's third + // case describes and §3 drives. The caught half (21, 13 on the + // invocation line) is untouched. No prose mention was added, so the + // two numbers move together (+2 / +2). + // // [#20237] 68 → 66 sites / 92 → 90 mentions — a MOVE, not a lost // consumer. `GET /meta/:type`'s app nav filter and dashboard widget // gate each resolved the context inline (two same-line CAUGHT sites); @@ -457,11 +466,11 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { // naming the seam is the point of the sentence — and the sentence // moving only the mention count is this control working: a site was not // added, and the number that tracks sites did not move. - expect(SITES.length).toBe(66); - expect(SOURCE.split('resolveExecCtx').length - 1).toBe(90); + expect(SITES.length).toBe(68); + expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92); }); - it('the split is 21 locally caught / 45 bare — NOT 13 / 45, which does not add to 66', () => { + it('the split is 21 locally caught / 47 bare — NOT 13 / 47, which does not add to 68', () => { // 13 sites spell the catch on the invocation line; 8 more spell it on // the continuation line. A single-line grep sees 13 and the arithmetic // silently loses eight sites. [#20237] 15 → 13 and 23 → 21: the list @@ -473,27 +482,27 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch(')); expect(sameLine.length).toBe(13); expect(CAUGHT.length).toBe(21); - expect(BARE.length).toBe(45); + expect(BARE.length).toBe(47); expect(CAUGHT.length + BARE.length).toBe(SITES.length); }); - it('⭐ every one of the 45 bare sites is guarded on the VERY NEXT LINE, and none of the 21 caught ones is', () => { + it('⭐ every one of the 47 bare sites is guarded on the VERY NEXT LINE, and none of the 21 caught ones is', () => { // This inverts the reason the thread gave for doing the bare sites // first ("no local signal that a fault becomes an anonymous subject"). // The bare sites are bare BECAUSE the shared anonymous floor is the // next statement; the locally-caught ones carry a `.catch` because // they are NOT behind that floor and each must decide for itself. - expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(45); + expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(47); expect(CAUGHT.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(0); }); }); // --------------------------------------------------------------------------- -// 3. The 45 bare sites, driven +// 3. The 47 bare sites, driven // --------------------------------------------------------------------------- -describe('[#13160] §3 the 45 bare sites — driven, every one of them', () => { - it('all 45 are reached by the mounted route table, so none is classified by inference', async () => { +describe('[#13160] §3 the 47 bare sites — driven, every one of them', () => { + it('all 47 are reached by the mounted route table, so none is classified by inference', async () => { const reached = sitesOf(await sweep(undefined, 'FULL')); const unreached = BARE.map((s) => s.line).filter((l) => !reached.has(l)); // ⛔ A bare site that stopped being reachable must show up as a @@ -501,14 +510,14 @@ describe('[#13160] §3 the 45 bare sites — driven, every one of them', () => { expect(unreached).toEqual([]); }, 120_000); - it('an absent context is the ANONYMOUS SUBJECT at all 45: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => { + it('an absent context is the ANONYMOUS SUBJECT at all 47: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => { const fault = await sweep(undefined, 'FULL'); const control = await sweep(ENTITLED, 'FULL'); const bareLines = new Set(BARE.map((s) => s.line)); const controlByRoute = new Map(control.map((r) => [r.route, r])); const rows = fault.filter((r) => r.sites.some((l) => bareLines.has(l))); - expect(rows.length).toBeGreaterThanOrEqual(45); + expect(rows.length).toBeGreaterThanOrEqual(47); for (const row of rows) { expect(row.status, `${row.route} under an absent context`).toBe(ANONYMOUS_DENY_STATUS); diff --git a/packages/rest/src/rest-openapi-info-overlay.test.ts b/packages/rest/src/rest-openapi-info-overlay.test.ts index 6d98adeed5f..acc062e4b33 100644 --- a/packages/rest/src/rest-openapi-info-overlay.test.ts +++ b/packages/rest/src/rest-openapi-info-overlay.test.ts @@ -55,6 +55,9 @@ function makeRest(api: Record) { makeProtocol(), { api: { version: 'v1', enableProjectScoping: true, projectResolution: 'auto', ...api } } as any, ); + // [#22430] Both doors refuse an anonymous caller; the overlay is what a + // signed-in reader is served, so that is the caller these cases drive. + (rest as any).resolveExecCtx = async () => ({ userId: 'u_docs_reader' }); rest.registerRoutes(); return rest; } diff --git a/packages/rest/src/rest-openapi-route.test.ts b/packages/rest/src/rest-openapi-route.test.ts index 230ea55ff4c..15d2595bddc 100644 --- a/packages/rest/src/rest-openapi-route.test.ts +++ b/packages/rest/src/rest-openapi-route.test.ts @@ -57,6 +57,10 @@ function makeRest(protocol: any, config: Record = {}) { protocol, { api: { requireAuth: false, version: 'v1', ...config } } as any, ); + // [#22430] The document refuses an anonymous caller, so every case here is a + // signed-in one — what is described is the document a reader is served. The + // refusal itself is pinned in `rest-api-description-anonymous-deny.test.ts`. + (rest as any).resolveExecCtx = async () => ({ userId: 'u_docs_reader' }); rest.registerRoutes(); return rest; } From fc4d39d614ea0fd9ec6c94716174185a623e9175 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:54:57 +0000 Subject: [PATCH 4/9] test(dogfood): classify the API-description family; drive its anonymous refusal on the booted showcase Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- .../dogfood/test/authz-conformance.matrix.ts | 10 +++ .../test/authz-ledger-population.baseline.ts | 11 +-- ...se-anonymous-deny-surfaces.dogfood.test.ts | 67 +++++++++++++++++++ 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 2b194ece503..d7b82202c88 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -284,6 +284,16 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ // which stays in the shrink-only baseline. covers: ['analytics:domains/analytics.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/analytics'], note: 'Ungated, an unauthenticated caller — carried to the domain as the guest envelope — was answered 200 on every analytics face on a stock boot, with aggregates over arbitrary objects, while the record doors and the dataset door on the same boot answered it 401. Gating the DOMAIN rather than each face keeps a newly added analytics face from arriving ungated. This row is the door half only: a non-system caller who resolves NO permission set being granted object admission and an unscoped read by that absence is a security-layer question for every door at once, deliberately not copied into service-analytics; it is tracked on its own card. The per-face unit pins (anonymous 401 with the service never consulted, malformed body and empty slot still 401, signed-in control unchanged) live in runtime/domains/analytics-anonymous-deny.test.ts.' }, + // #22430 — the API-description endpoints, under the ruling recorded on #22146 + // (the API-description endpoints refuse an anonymous caller with 401). + { id: 'anonymous-deny-api-description', summary: 'anonymous-deny on the API-description endpoints (the document and its viewer)', state: 'enforced', + enforcement: 'rest/rest-server.ts registerOpenApiEndpoints — BOTH handlers (the document and its viewer page, on every base the server mounts) open with enforceAuth (→ shouldDenyAnonymous) as their first act, ahead of the bundled-artifact load and the protocol resolution, so a refused request does no work and does not reveal whether a document is bundled; an anonymous caller gets the REST seam\'s flat 401 UNAUTHENTICATED, a signed-in caller is served as before, and a session held by the ADR-0069 auth-policy gate gets that gate\'s 403', + proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts', + // The REST family at ledger granularity. The family is exactly these two + // routes and both handlers open with the floor, which is the property a + // family key needs. + covers: ['rest-family:rest-route-ledger.ts:openapi'], + note: 'Measured on a booted showcase before the change: an unauthenticated caller was served the document and the viewer page with 200, just as a signed-in caller was, while the record doors on the same boot answered it 401 — ADR-0056 D2 is default-deny and no ADR-0138 D2 door class names these endpoints. The cited proof drives both endpoints anonymously (401, the REST flat envelope, nothing of either served) and with a signed-in member (200, the document / the viewer page) on one boot. The environment-scoped twin mounts only under project scoping, which the showcase does not enable; it is the same handler closure, pinned per base in rest/src/rest-api-description-anonymous-deny.test.ts beside the gated-session 403 and the permission-store-outage 503. The viewer page fetches the document from the browser, and that request carries the browser\'s session (measured with the real viewer in a browser), so a signed-in browser is served on both endpoints; a deployment that publishes its API description to readers without an account publishes a static copy of the document instead.' }, // ── #2992 / ADR-0096 D4 — latent execution surfaces (pre-wiring identity // admission). Neither surface is reachable by a client today; these rows diff --git a/packages/qa/dogfood/test/authz-ledger-population.baseline.ts b/packages/qa/dogfood/test/authz-ledger-population.baseline.ts index 42ba03348d4..f1caf570ad0 100644 --- a/packages/qa/dogfood/test/authz-ledger-population.baseline.ts +++ b/packages/qa/dogfood/test/authz-ledger-population.baseline.ts @@ -70,7 +70,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. The other 18 families are here. + // covers it through the guarded registrar — and so is `openapi`, classified + // by `anonymous-deny-api-description` since #22430. The other 16 families + // are here. 'rest-family:rest-route-ledger.ts:analytics', 'rest-family:rest-route-ledger.ts:approvals', 'rest-family:rest-route-ledger.ts:batch', @@ -80,7 +82,6 @@ export const LEDGER_POPULATION_BASELINE: readonly string[] = [ 'rest-family:rest-route-ledger.ts:email', 'rest-family:rest-route-ledger.ts:external-datasource', 'rest-family:rest-route-ledger.ts:forms', - 'rest-family:rest-route-ledger.ts:openapi', // ⚠️ 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 @@ -126,6 +127,8 @@ export const LEDGER_POPULATION_BASELINE: readonly string[] = [ * 2026-09-25 (#20102): `rest-family:rest-route-ledger.ts:reports` left with the * retired saved-report family — deleted, not classified. 32 at 2026-10-01 * (#21061): `dispatcher-domain:route-ledger.ts:/analytics` left classified, by - * the `anonymous-deny-analytics` row and its domain gate pin. + * the `anonymous-deny-analytics` row and its domain gate pin. 31 at 2026-10-09 + * (#22430): `rest-family:rest-route-ledger.ts:openapi` left classified, by the + * `anonymous-deny-api-description` row and its booted-showcase proof. */ -export const LEDGER_POPULATION_BASELINE_MAX = 32; +export const LEDGER_POPULATION_BASELINE_MAX = 31; diff --git a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts index fdab9adb174..5c3282b8624 100644 --- a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts @@ -50,6 +50,17 @@ // authz-row: anonymous-deny-automation // authz-row: anonymous-deny-packages // authz-row: anonymous-deny-analytics +// authz-row: anonymous-deny-api-description +// +// ── Why the API-description endpoints are here (#22430) ──────────────────── +// +// `@objectstack/rest`'s `registerOpenApiEndpoints` mounts the API-description +// endpoints — the document and its viewer page — and until #22430 neither +// handler asked who was calling: on a stock boot both answered an +// unauthenticated caller 200, exactly as they answered a signed-in one, while +// the record doors beside them answered 401. Both are driven below anonymously +// (401 in the REST flat envelope, nothing of either served) and with a +// signed-in member as the 200 control, on this one boot. // // ── Why the analytics faces are here (#21061) ────────────────────────────── // @@ -137,6 +148,21 @@ const ANALYTICS_FACES: readonly AnalyticsFace[] = [ { face: 'the analytics meta face', method: 'GET', path: '/analytics/meta' }, ]; +// ── #22430 — the API-description endpoints (@objectstack/rest) ────────────── +// +// Both register on every base the server mounts; this boot mounts only the +// unscoped one (the showcase does not enable project scoping), and the +// environment-scoped twin — the same handler closures — is pinned per base in +// `packages/rest/src/rest-api-description-anonymous-deny.test.ts`. +interface ApiDescriptionEndpoint { + readonly endpoint: string; + readonly path: string; +} +const API_DESCRIPTION_ENDPOINTS: readonly ApiDescriptionEndpoint[] = [ + { endpoint: 'the API-description document', path: '/openapi.json' }, + { endpoint: 'the API-description viewer', path: '/docs' }, +]; + // ── #11373 — the /meta WRITE doors, driven through the REAL mount ────────── // // The `/meta` cases above this line were, for this file's whole life, ONE @@ -636,6 +662,40 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => }, ); + // ── the API-description endpoints (@objectstack/rest) — #22430 ────────── + // + // The refusal is the handler's first act, so the anonymous body is the REST + // seam's shared one, whole, and carries nothing of the document or the page. + // The member control is the teeth: the same endpoint, with a session, is + // served 200 on this boot, so the 401 is the floor's answer and the endpoint + // is really mounted here. + it.each(API_DESCRIPTION_ENDPOINTS)('an anonymous caller on $endpoint is denied (401) and served nothing', async (ep) => { + const r = await anon('GET', ep.path); + expect(r.status, `${ep.endpoint}: an anonymous caller must be refused by the floor`).toBe(ANONYMOUS_DENY_STATUS); + expect(r.headers.get('content-type') ?? '', `${ep.endpoint}: the refusal is JSON, never the page`).toContain('application/json'); + const body = await r.json(); + expect(body, `${ep.endpoint}: the refusal is the REST seam's shared body, whole`).toEqual(ANONYMOUS_DENY_BODY); + expect(body, `${ep.endpoint}: nothing of the document rides on the refusal`).not.toHaveProperty('openapi'); + expect(body, `${ep.endpoint}: nothing of the document rides on the refusal`).not.toHaveProperty('paths'); + }); + + it('a signed-in member is served the API-description document (200) — the control', async () => { + const r = await stack.apiAs(memberToken, 'GET', '/openapi.json'); + expect(r.status, 'an authenticated member must clear the floor and be served the document').toBe(200); + const body = await r.json(); + expect(body.openapi, 'the served body is the OpenAPI 3.1 document').toBe('3.1.0'); + expect(Object.keys(body.paths ?? {}).length, 'the document describes the mounted surface').toBeGreaterThan(0); + }); + + it('a signed-in member is served the API-description viewer page (200) — the control', async () => { + const r = await stack.apiAs(memberToken, 'GET', '/docs'); + expect(r.status, 'an authenticated member must clear the floor and be served the viewer').toBe(200); + expect(r.headers.get('content-type') ?? '', 'the viewer is an HTML page').toContain('text/html'); + // The page loads its sibling document on the same base — the request that, + // from a signed-in browser, carries that browser's session. + expect(await r.text(), 'the viewer points at its sibling document').toContain('data-url="/api/v1/openapi.json"'); + }); + // ── one code, one message — two wrappers ─────────────────────────────── it('every denied surface answers the SAME code and message (the wrappers differ)', async () => { const rest = await Promise.all([ @@ -740,6 +800,13 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => // [#21060] The two remaining REST families the checklist item names. { seam: 'POST /batch', owner: '@objectstack/rest enforceAuth', family: 'rest-flat', call: () => anon('POST', BATCH, BATCH_PROBE_BODY) }, { seam: 'GET /security/explain', owner: '@objectstack/rest enforceAuth', family: 'rest-flat', call: () => anon('GET', EXPLAIN) }, + // [#22430] The API-description endpoints, labelled by endpoint. + ...API_DESCRIPTION_ENDPOINTS.map((ep) => ({ + seam: ep.endpoint, + owner: '@objectstack/rest registerOpenApiEndpoints (enforceAuth)', + family: 'rest-flat' as DenyFamily, + call: () => anon('GET', ep.path), + })), { seam: 'POST /actions/:object/:action/:id', owner: 'runtime domains/actions.ts', family: 'dispatcher-wrapper', call: () => anon('POST', ACTION, { params: {} }) }, { seam: 'POST /automation/:name/trigger', owner: 'runtime domains/automation.ts', family: 'dispatcher-wrapper', call: () => anon('POST', `/automation/${FLOW}/trigger`, {}) }, { seam: 'GET /automation/_status', owner: 'runtime domains/automation.ts', family: 'dispatcher-wrapper', call: () => anon('GET', '/automation/_status') }, From 65bbc33f1caff19eb156c2913155a1987b5987f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:58:38 +0000 Subject: [PATCH 5/9] test(dogfood): the openapi family is classified in the population pin Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- packages/qa/dogfood/test/authz-conformance.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index 005815ebccf..e874e69f8e9 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -977,6 +977,8 @@ describe('the ledger-sourced population and its baseline bite', () => { 'dispatcher-domain:route-ledger.ts:/meta', 'dispatcher-domain:route-ledger.ts:/packages', 'rest-family:rest-route-ledger.ts:metadata', + // [#22430] classified by `anonymous-deny-api-description`. + 'rest-family:rest-route-ledger.ts:openapi', ]); // …and the two halves partition the population exactly: nothing minted by // a ledger probe is both, and nothing is neither. From 1d4ad614f4fca813235206f72d5cc01bb2194009 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 08:02:44 +0000 Subject: [PATCH 6/9] chore(changeset): the API-description endpoints refuse an anonymous caller; pin the inherited security statement Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- .../22430-api-description-anonymous-deny.md | 13 +++++++++++++ .../rest-api-description-anonymous-deny.test.ts | 16 ++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 .changeset/22430-api-description-anonymous-deny.md diff --git a/.changeset/22430-api-description-anonymous-deny.md b/.changeset/22430-api-description-anonymous-deny.md new file mode 100644 index 00000000000..9715f461fbe --- /dev/null +++ b/.changeset/22430-api-description-anonymous-deny.md @@ -0,0 +1,13 @@ +--- +'@objectstack/rest': patch +--- + +The API-description endpoints refuse an anonymous caller + +Clause-②: no + +`RestServer.registerOpenApiEndpoints` mounts the API-description endpoints: the document and its viewer page. Until now they served a caller with no session, while the data and metadata routes beside them refused one. ADR-0056 D2 denies anonymous callers by default. + +Both handlers now start with the same anonymous-deny check the data routes use, before any other work. An anonymous request gets `401` with code `UNAUTHENTICATED`, the same body the data routes answer. A signed-in caller is served as before. A session held by an auth policy (an expired password, enforced MFA) gets that policy's `403`, as on every other protected route. The viewer page loads the document from the browser with the browser's session, so a signed-in browser sees the viewer work as before. + +If you published the API description to readers without an account, have them sign in first, or publish a static copy of the document instead. diff --git a/packages/rest/src/rest-api-description-anonymous-deny.test.ts b/packages/rest/src/rest-api-description-anonymous-deny.test.ts index 04cb7c78f01..5a5bf953586 100644 --- a/packages/rest/src/rest-api-description-anonymous-deny.test.ts +++ b/packages/rest/src/rest-api-description-anonymous-deny.test.ts @@ -168,6 +168,22 @@ describe('[#22430] a signed-in caller is served as before', () => { expect(protocol.getMetaItems).toHaveBeenCalledWith({ type: 'object' }); }); + it(`the document on ${base} describes both endpoints as needing credentials — now true of them`, async () => { + // Neither operation carries its own `security`, so each inherits the + // document-level requirement; with the refusal in place that is the + // truth about them, where before it under-claimed (see the coverage + // note in `openapi-builtin-paths.ts`). + const { rest } = signedIn(); + const doc: any = (await drive(rest, `${base}/openapi.json`, envOf(base))).body; + expect(Array.isArray(doc.security) && doc.security.length > 0, 'the document states a requirement').toBe(true); + const template = base.replace(':environmentId', '{environmentId}'); + for (const ep of ENDPOINTS) { + const op = doc.paths?.[`${template}${ep.tail}`]?.get; + expect(op, `${ep.name} is described in the document`).toBeDefined(); + expect(op).not.toHaveProperty('security'); + } + }); + it(`the viewer on ${base} is the HTML page pointed at its sibling document`, async () => { const { rest } = signedIn(); const r = await drive(rest, `${base}/docs`, envOf(base)); From 0c0b1f7e50e0fb2be232eca42c9175eb1f2ae5d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 09:00:40 +0000 Subject: [PATCH 7/9] test(dogfood): re-measure the authz probe census for the classified API-description family The anonymous-deny-api-description row covers one more key (17 -> 18 covers keys; 8 ledger keys classified, 31 baselined), and the two API-description handlers add two enforceAuth call sites to rest-server.ts (56 -> 58). Population, reach and blind spot re-derive unchanged (71 / 19 / 52). Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- .../test/authz-probe-blind-spot.census.ts | 18 ++++++++++++++---- .../test/authz-probe-blind-spot.test.ts | 13 +++++++------ 2 files changed, 21 insertions(+), 10 deletions(-) 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 a5059fb86e4..871e85e67d6 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -287,7 +287,10 @@ export interface ProbeTableReading { // table as a GATE_PIN on `packages/runtime/src/domains/analytics.ts`, and the // `anonymous-deny-analytics` row covers its key plus the `/analytics` // dispatcher-domain key that left the shrink-only ledger baseline. -export const PROBE_TABLE: ProbeTableReading = { entries: 19, files: 14, keys: 17 }; +// [#22430] 19 / 14 / 17 -> 19 / 14 / 18: no probe and no file joined; the +// `anonymous-deny-api-description` row covers one more key, the `openapi` REST +// family, which left the shrink-only ledger baseline. +export const PROBE_TABLE: ProbeTableReading = { entries: 19, files: 14, keys: 18 }; /** * The probe count `authz-conformance.matrix.ts`'s header states. @@ -347,8 +350,8 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ '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; 1 classified by a ' + - 'matrix row (metadata), 17 enumerated in the shrink-only baseline. Re-measured 83 -> 82 when the anonymous ' + + 'and therefore cannot be silently absent from the authz ratchet either. 18 families; 2 classified by ' + + 'matrix rows (metadata, and openapi since #22430), 16 enumerated in the shrink-only baseline. 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 ' + 'still carry, so `reachable` moved with `population` and the families stay 18. Earlier re-measured 91 -> 83 (19 -> 18 families) ' + 'when the whole saved-report `reports` family left with its eight routes, all eight carrying the family, so ' + @@ -499,6 +502,13 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // not move: the handler never called `enforceAuth` (the public-form routes // bypass it), so that stays 56, and `registerFormEndpoints` survives, so // `private register*Endpoints(` stays 16. + // [#22430] `enforceAuth` 56 -> 58, and nothing else on this row moved: + // 71 / 19 / 52 and every other control re-derived unchanged. The two + // API-description handlers in `registerOpenApiEndpoints` (the document and + // its viewer) now open with `if (this.enforceAuth(req, res, context)) + // return;` — two call sites, no prose mention. Both routes were already in + // the population and stay outside `registerMetadataEndpoints`, so the blind + // spot is unchanged: guarding a route is not reaching it with a key. controls: { 'private register*Endpoints(': 16, 'this.routeManager.register(': 64, @@ -509,7 +519,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'registerPerItemRoute(': 8, 'const registerPerItemRoute =': 1, 'forwarder slice: this.routeManager.register(': 1, - enforceAuth: 56, + enforceAuth: 58, }, note: 'The single non-tripwire probe names ONE registrar of 16. The other 15 can never mint a key: ' + 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 82e1b494c1a..b74db32d9d5 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.test.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.test.ts @@ -43,19 +43,20 @@ describe('authz probe blind-spot census (#13260)', () => { expect(derived.table).toEqual(PROBE_TABLE); }); - it('every classified key is accounted for — 17 `covers` keys, no more', () => { - // The matrix's `covers` keys number 17 today: the 9 probe-minted keys this + it('every classified key is accounted for — 18 `covers` keys, no more', () => { + // The matrix's `covers` keys number 18 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). If a probe + // domain's gate-pin key and its dispatcher-domain key (#21061), plus the + // `openapi` REST family key (#22430). 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 39 keys; 7 - // are classified here and 32 are enumerated in the shrink-only baseline, + // ⚠️ This is NOT the size of the population. The ledgers mint 39 keys; 8 + // are classified here and 31 are enumerated in the shrink-only baseline, // which `authz-conformance.test.ts` holds to its own four rules. - expect(PROBE_TABLE.keys).toBe(17); + expect(PROBE_TABLE.keys).toBe(18); }); it.each(PROBE_FILE_CENSUS.map((r) => [r.file, r] as const))( From d1cc56281a4559b8686249ecb581b5d13f5c6562 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 09:12:16 +0000 Subject: [PATCH 8/9] test(dogfood): keep the tracker id out of the census note's string prose The rest-route-ledger row's note named the issue inside a string literal, which check:doc-authoring refuses in sibling-package prose. The anchor moves to an adjacent comment; the note keeps its counts. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- packages/qa/dogfood/test/authz-probe-blind-spot.census.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 871e85e67d6..bb35642f2a7 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -345,13 +345,15 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ blindSpot: 0, populationRule: 'ledger rows inside REST_ROUTE_LEDGER; reachable = rows carrying a `family` (each distinct value mints a key)', controls: { "route: '": 82, "family: '": 82, 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. 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 since #22430), 16 enumerated in the shrink-only baseline. Re-measured 83 -> 82 when the anonymous ' + + 'matrix rows (metadata and openapi), 16 enumerated in the shrink-only baseline. 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 ' + 'still carry, so `reachable` moved with `population` and the families stay 18. Earlier re-measured 91 -> 83 (19 -> 18 families) ' + 'when the whole saved-report `reports` family left with its eight routes, all eight carrying the family, so ' + From 9aaebffb35891b6eb4d170fad3610550521af398 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 10:54:26 +0000 Subject: [PATCH 9/9] chore(changeset): declare the API-description refusal a narrowing (minor, breaking) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing a door that served an anonymous caller narrows what the door accepts: the changeset becomes minor with the breaking mark, declares Clause-② no (narrowing), and carries its ADR-0087 disposition. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- .../22430-api-description-anonymous-deny.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.changeset/22430-api-description-anonymous-deny.md b/.changeset/22430-api-description-anonymous-deny.md index 9715f461fbe..67de542c82e 100644 --- a/.changeset/22430-api-description-anonymous-deny.md +++ b/.changeset/22430-api-description-anonymous-deny.md @@ -1,13 +1,19 @@ --- -'@objectstack/rest': patch +'@objectstack/rest': minor --- -The API-description endpoints refuse an anonymous caller +fix(rest)!: the API-description endpoints refuse an anonymous caller -Clause-②: no +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. `RestServer.registerOpenApiEndpoints` mounts the API-description endpoints: the document and its viewer page. Until now they served a caller with no session, while the data and metadata routes beside them refused one. ADR-0056 D2 denies anonymous callers by default. -Both handlers now start with the same anonymous-deny check the data routes use, before any other work. An anonymous request gets `401` with code `UNAUTHENTICATED`, the same body the data routes answer. A signed-in caller is served as before. A session held by an auth policy (an expired password, enforced MFA) gets that policy's `403`, as on every other protected route. The viewer page loads the document from the browser with the browser's session, so a signed-in browser sees the viewer work as before. +- **Refused now:** a caller with no session, with `401` and code `UNAUTHENTICATED` — the same body the data routes answer. Both handlers check before any other work, so the refusal does not say whether a document is bundled. +- **Also refused:** a session held by an auth policy (an expired password, enforced MFA) gets that policy's `403`, as on every other protected route. +- **Unchanged:** a signed-in caller is served the same document and the same viewer page as before. The viewer page loads the document from the browser with the browser's session, so a signed-in browser sees the viewer work as before. -If you published the API description to readers without an account, have them sign in first, or publish a static copy of the document instead. +What changes for you. If you published the API description to readers without an account, have them sign in first, or publish a static copy of the document instead.