From b02a69f99722ca49440e9017936fa5e433f880d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 01:55:03 +0000 Subject: [PATCH 1/3] test(dogfood): WIP anonymous door sweep over the registered route table Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H --- .../anonymous-door-sweep.classification.ts | 339 +++++++++++ .../test/anonymous-door-sweep.dogfood.test.ts | 550 ++++++++++++++++++ 2 files changed, 889 insertions(+) create mode 100644 packages/qa/dogfood/test/anonymous-door-sweep.classification.ts create mode 100644 packages/qa/dogfood/test/anonymous-door-sweep.dogfood.test.ts diff --git a/packages/qa/dogfood/test/anonymous-door-sweep.classification.ts b/packages/qa/dogfood/test/anonymous-door-sweep.classification.ts new file mode 100644 index 00000000000..eb86b66c068 --- /dev/null +++ b/packages/qa/dogfood/test/anonymous-door-sweep.classification.ts @@ -0,0 +1,339 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The CLOSED classification the anonymous door sweep judges every mounted route +// by (ADR-0138 D2; the test half of ruling 6134095444 on #22146). +// +// ── What this table is ─────────────────────────────────────────────────────── +// +// D2 says an unauthenticated request is served by exactly six door classes, +// that a short list of surfaces is "outside the guest model by construction", +// and that EVERYTHING ELSE answers 401. This module writes that sentence down as +// data the sweep can execute: +// +// - `DOOR_CLASSES` the six classes, each with the routes that carry it +// and the class-level answer D2 gives an anonymous +// request that names no declared subject; +// - `OUTSIDE_FAMILIES` the surfaces outside the guest model by +// construction, each with the reason it is outside; +// - `MIDDLEWARE` registrations that answer no request of their own; +// - and NOTHING ELSE. A route none of the above claims is in the +// everything-else class, and must answer 401. +// +// ⛔ That last line is the whole point. There is no "unclassified" bucket and no +// tolerance list: a new anonymous door that nobody wrote down here lands in the +// everything-else class and turns the sweep red, which is the "a new surface is +// therefore 401 until this list names it" sentence of D2 made mechanical. +// Naming a new door class is an amendment of ADR-0138, never an edit here alone. +// +// ── What a row may and may not say ─────────────────────────────────────────── +// +// Rows are keyed by the pattern the router registered (`METHOD pattern`, `*` for +// every verb), or by the platform's own predicate where one exists (the +// control-plane allowlist is `isAuthGateAllowlisted` itself, never a copy of its +// paths). Every row carries the reason it is where it is. +// +// The expectations are CLASS-LEVEL: one predicate per class, over the status +// class and over whether anything was served. No row pins one route's own +// answer, and no measured route-level reading belongs in this file (#22146: +// classes, positions and functions only). +// +// ── Ordering ───────────────────────────────────────────────────────────────── +// +// First match wins, in this order: door classes, outside families, the +// control-plane allowlist, middleware. The explicit outside families sit ahead +// of the allowlist on purpose: two of them (the approval action page and the +// signed inbound hook) are ALSO admitted by the allowlist predicate, and each +// deserves its own reason and, for the hook, its own D2-stated answer. + +import { isAuthGateAllowlisted } from '@objectstack/core'; + +/** One route of the swept population. */ +export interface SweptRoute { + /** `GET`, `POST`, … or `ALL` for a framework registration on every verb. */ + readonly method: string; + /** The registered pattern (`/api/v1/meta/:type`), or a declared endpoint's literal path. */ + readonly pattern: string; + /** + * Where the population read it from: + * - `port` — `IHttpServer.getMountedRoutes()`, the adapter's record of every + * route registered through the HTTP-server contract; + * - `raw` — the framework's own route table only (a mount on the raw app + * handle, outside the port by construction); + * - `declared` — an ADR-0121 declared endpoint, served by the transport's + * fallback seam and therefore in no route table at all. + */ + readonly source: 'port' | 'raw' | 'declared'; + /** For `declared`: the parsed declaration the metadata service matches. */ + readonly endpoint?: { readonly type?: string; readonly authRequired?: boolean }; +} + +/** What one anonymous request got back, as the sweep records it. */ +export interface SweepAnswer { + readonly status: number; + readonly contentType: string; + /** The parsed body when the answer was JSON, else `undefined`. */ + readonly json?: Record; +} + +/** `undefined` when the answer is the class's answer, otherwise why it is not. */ +export type ClassExpectation = (answer: SweepAnswer) => string | undefined; + +const isJson = (a: SweepAnswer): boolean => a.json !== undefined; + +/** Nothing served: an error envelope, never a `data` payload or a success flag. */ +function servedNothing(a: SweepAnswer): string | undefined { + if (!isJson(a)) return undefined; + if (a.json!.success === true) return 'the body says success:true'; + if ('data' in a.json!) return 'the body carries a data payload'; + return undefined; +} + +/** The everything-else answer: 401, as an error envelope, with nothing served. */ +export const EVERYTHING_ELSE: ClassExpectation = (a) => { + if (a.status !== 401) return `answered ${a.status}, not 401`; + if (!isJson(a) || a.json!.error === undefined) return 'answered 401 without an error envelope'; + return servedNothing(a); +}; + +/** A door that admits only by a declared subject, asked about none: a 4xx refusal, nothing served. */ +const REFUSED_WITHOUT_A_SUBJECT: ClassExpectation = (a) => { + if (a.status < 400 || a.status >= 500) return `answered ${a.status}, not a 4xx refusal`; + return servedNothing(a); +}; + +const codeOf = (a: SweepAnswer): unknown => { + const error = a.json?.error; + return error && typeof error === 'object' ? (error as { code?: unknown }).code : a.json?.code; +}; + +export type DoorClassId = 1 | 2 | 3 | 4 | 5 | 6; + +export interface DoorClass { + readonly id: DoorClassId; + /** D2's own name for the class. */ + readonly name: string; + /** D2's enforcer for it, by function. */ + readonly enforcedBy: string; + /** + * The routes that carry this class. `undefined` for the one class that has no + * route of its own (class 3 rides the shared `/meta` read routes, whose every + * other reading is everything-else); see `ridesOn`. + */ + readonly match?: (r: SweptRoute) => boolean; + /** For a class with no route of its own: the routes it rides, all in the population. */ + readonly ridesOn?: readonly string[]; + /** The class-level answer to the sweep's request on one of its routes. */ + readonly expect?: ClassExpectation; +} + +const routeIs = (...keys: string[]) => { + const set = new Set(keys); + return (r: SweptRoute) => set.has(`${r.method} ${r.pattern}`); +}; + +const declaredAnonymous = (type: string) => (r: SweptRoute) => + r.source === 'declared' && r.endpoint?.authRequired === false && r.endpoint?.type === type; + +/** ADR-0138 D2's six door classes. */ +export const DOOR_CLASSES: readonly DoorClass[] = [ + { + id: 1, + name: 'the public form doors (resolve and submit)', + enforcedBy: 'RestServer.registerFormEndpoints; the publicFormGrant branch of SecurityPlugin', + match: routeIs('GET /api/v1/forms/:slug', 'POST /api/v1/forms/:slug/submit'), + // Admitted by the form's own `sharing` declaration; a slug no form declares + // is no door. + expect: REFUSED_WITHOUT_A_SUBJECT, + }, + { + id: 2, + name: 'share links', + enforcedBy: 'registerShareLinkRoutes (plugin-sharing), the capability token', + // The two token-addressed reads. The create / list / revoke routes beside + // them manage links and need a signed-in caller: they are everything-else. + match: routeIs('GET /api/v1/share-links/:token/resolve', 'GET /api/v1/share-links/:token/messages'), + expect: REFUSED_WITHOUT_A_SUBJECT, + }, + { + id: 3, + name: 'the public book and doc metadata reads', + enforcedBy: 'isPublicAudienceRead (rest meta-item-read-gate), the ADR-0046 §6.7 audience gate', + // No route of its own: the audience gate admits by the book's + // `audience: 'public'` VALUE, on the same read routes every other metadata + // read takes. Asked about any other subject those routes are + // everything-else and answer 401, so the sweep holds them to 401 and the + // class itself is asserted on its real subjects. + ridesOn: ['GET /api/v1/meta/:type', 'GET /api/v1/meta/:type/:name', 'GET /api/v1/meta/book/:name/tree'], + }, + { + id: 4, + name: "authRequired: false endpoints of type 'object_operation'", + enforcedBy: 'resolveExecutionContext (the guest envelope); the CRUD gate', + match: declaredAnonymous('object_operation'), + // The guest envelope under the guest's grants (D3). No grant is bound to + // the guest on this boot, so D3's empty state applies: deny all. + expect: (a) => { + if (a.status !== 403) return `answered ${a.status}, not D3's empty-state 403`; + if (codeOf(a) !== 'PERMISSION_DENIED') return `answered 403 with code ${String(codeOf(a))}`; + return servedNothing(a); + }, + }, + { + id: 5, + name: "authRequired: false endpoints of type 'flow'", + enforcedBy: 'resolveExecutionContext (the guest envelope), then @objectstack/service-automation', + match: declaredAnonymous('flow'), + // Admitted at the door as the guest; what the run answers is the flow's own + // declared behaviour, asserted on the class's fixture, not here. + expect: (a) => (a.status === 401 ? 'answered 401: the door did not admit the guest' : undefined), + }, + { + id: 6, + name: "the acl: 'public_read' file download", + enforcedBy: 'registerStorageRoutes, at the download gate authorizeDownload both download routes call', + match: routeIs('GET /api/v1/storage/files/:fileId', 'GET /api/v1/storage/files/:fileId/url'), + // Admitted by the file's own `acl: 'public_read'`; an id that names no such + // file is no door. + expect: REFUSED_WITHOUT_A_SUBJECT, + }, +]; + +export type OutsideFamilyId = + | 'signed-inbound-hook' + | 'mcp' + | 'mcp-skill-document' + | 'approval-action-token' + | 'storage-signed-url' + | 'discovery' + | 'oauth-discovery-metadata' + | 'console-bootstrap' + | 'dev-metadata-hmr' + | 'control-plane-allowlist'; + +export interface OutsideFamily { + readonly id: OutsideFamilyId; + /** Why this surface is outside the guest model by construction. */ + readonly reason: string; + readonly match: (r: SweptRoute, probePath: string) => boolean; + /** Only where D2 itself states the anonymous answer. */ + readonly expect?: ClassExpectation; +} + +const statusIs401: ClassExpectation = (a) => (a.status === 401 ? servedNothing(a) : `answered ${a.status}, not 401`); + +/** + * The surfaces outside the guest model by construction. The first three are + * D2's own list (the allowlist, the signed inbound-hook channel, MCP); the rest + * are the token-addressed approval pages and the raw-app mounts ruling + * 6134095444 ordered classified explicitly, each with its reason. + */ +export const OUTSIDE_FAMILIES: readonly OutsideFamily[] = [ + { + id: 'signed-inbound-hook', + reason: + 'ADR-0041 signed inbound hook (trigger-api verifySignature): the per-flow secret is the credential, and ' + + 'D2 states the answer to a missing or bad signature: 401', + match: routeIs('POST /api/v1/automation/hooks/:flowName/:hookId'), + expect: statusIs401, + }, + { + id: 'mcp', + reason: 'MCP admits an OAuth token or an API key and never an anonymous caller (D2), so its answer is 401', + match: (r) => r.pattern === '/api/v1/mcp', + expect: statusIs401, + }, + { + id: 'mcp-skill-document', + reason: + 'the portable Agent Skill document (ADR-0036 Amendment C, renderSkillMarkdown): generic instructions and the ' + + "environment's MCP URL, never a tenant's schema or data; the verbs it does not serve answer for themselves", + match: (r) => r.pattern === '/api/v1/mcp/skill', + }, + { + id: 'approval-action-token', + reason: + "ADR-0043 actionable-link pages (ApprovalsServicePlugin's peek and redeem): the single-use action token is " + + 'the whole credential, and a decision binds the approver that token names, never a guest; ' + + 'isAuthGateAllowlisted admits this exact route', + match: routeIs('GET /api/v1/approvals/act', 'POST /api/v1/approvals/act'), + }, + { + id: 'storage-signed-url', + reason: + "the local storage adapter's signed byte URLs: the signed, expiring token an authorized upload or download " + + "mints is the credential, the role a cloud store's presigned URL plays, and no principal is evaluated", + match: routeIs('GET /api/v1/storage/_local/raw/:token', 'PUT /api/v1/storage/_local/raw/:token'), + }, + { + id: 'discovery', + reason: + "the discovery documents (the REST root and the dispatcher's well-known document): the allowlist's " + + 'discovery role, at the two spellings isAuthGateAllowlisted does not name literally', + match: routeIs('GET /api/v1', 'GET /.well-known/objectstack'), + }, + { + id: 'oauth-discovery-metadata', + reason: + 'the OAuth / OpenID discovery metadata (RFC 8414, RFC 9728) plugin-auth serves: public by standard, read by ' + + 'an MCP or OAuth client before it holds any token, and carrying no business data', + match: (r) => + r.method === 'GET' && + (r.pattern.startsWith('/.well-known/oauth-authorization-server') || + r.pattern.startsWith('/.well-known/oauth-protected-resource') || + r.pattern === '/.well-known/openid-configuration'), + }, + { + id: 'console-bootstrap', + reason: + "RuntimeConfigPlugin's bootstrap reads: the console's pre-sign-in branding and runtime posture, no tenant data", + match: routeIs('GET /api/v1/runtime/config', 'GET /api/v1/studio/runtime-config'), + }, + { + id: 'dev-metadata-hmr', + reason: + "MetadataPlugin's metadata HMR stream: a build-tool door mounted only under an explicit " + + 'NODE_ENV=development posture (isDevMetadataEndpointEnabled), never on a production deployment', + match: routeIs('GET /api/v1/dev/metadata-events', 'POST /api/v1/dev/metadata-events'), + }, + { + id: 'control-plane-allowlist', + reason: + 'the control-plane allowlist, by its own predicate (isAuthGateAllowlisted): sign-in and the auth surface, ' + + 'health, readiness, discovery and the current-user reads the ADR-0069 remediation screen needs', + match: (r, probePath) => isAuthGateAllowlisted(probePath, r.method === 'ALL' ? 'GET' : r.method), + }, +]; + +/** Registrations that run around other routes and answer no request of their own. */ +export const MIDDLEWARE: readonly { readonly key: string; readonly reason: string }[] = [ + { + key: 'ALL /*', + reason: 'the server-wide middleware (request context, CORS, error shaping): it wraps every route and serves none', + }, +]; + +export type SweepClass = + | { readonly kind: 'door'; readonly door: DoorClass } + | { readonly kind: 'outside'; readonly family: OutsideFamily } + | { readonly kind: 'middleware' } + | { readonly kind: 'everything-else' }; + +/** The one classification of a route. First match wins, in the order the header states. */ +export function classify(route: SweptRoute, probePath: string): SweepClass { + for (const door of DOOR_CLASSES) if (door.match?.(route)) return { kind: 'door', door }; + for (const family of OUTSIDE_FAMILIES) if (family.match(route, probePath)) return { kind: 'outside', family }; + if (MIDDLEWARE.some((m) => m.key === `${route.method} ${route.pattern}`)) return { kind: 'middleware' }; + return { kind: 'everything-else' }; +} + +/** + * A concrete request path for a registered pattern: every `:param` becomes a + * value no declaration can name, and a trailing `*` a segment under it. + */ +export function probePathOf(pattern: string): string { + let n = 0; + return pattern + .split('/') + .map((seg) => (seg.startsWith(':') ? `anon-sweep-${n++}` : seg === '*' ? 'anon-sweep-any' : seg)) + .join('/'); +} diff --git a/packages/qa/dogfood/test/anonymous-door-sweep.dogfood.test.ts b/packages/qa/dogfood/test/anonymous-door-sweep.dogfood.test.ts new file mode 100644 index 00000000000..5ba4c3e1880 --- /dev/null +++ b/packages/qa/dogfood/test/anonymous-door-sweep.dogfood.test.ts @@ -0,0 +1,550 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// THE ANONYMOUS DOOR SWEEP — ADR-0138 acceptance criterion 2, as a test that +// lives in the tree (#22146, the test half of ruling 6134095444). +// +// ## What it proves +// +// ADR-0138 D2: an unauthenticated request is served by exactly six door +// classes, a short list of surfaces is outside the guest model by construction, +// and everything else answers 401. Criterion 2 asks that a booted reference +// deployment's anonymous answer — its status class, and what is served — +// match that table, class by class. This file is that reading, repeatable: +// +// 1. it boots the showcase the way a deployment serves it; +// 2. it reads the REGISTERED route table off the running server — nothing +// here lists the population by hand; +// 3. it sends ONE unauthenticated request per route; +// 4. it judges each answer by the route's class in +// `anonymous-door-sweep.classification.ts`, which is closed: a route that +// table does not claim is in the everything-else class and must answer +// 401, so a new anonymous door nobody classified turns this file red; +// 5. and it asserts each of the six classes on a REAL subject, admitted and +// refused, because a sweep whose every probe names nothing can only ever +// observe the refusal half of a door. +// +// ## Where the population comes from — three sources, each guarded +// +// - the framework's own route table (`getRawApp().routes`): everything the +// router will route, the raw-app mounts included (`plugin-auth`'s +// catch-all and well-known documents, the approval pages, the inbound hook, +// the HMR stream, the runtime-config reads). The port's +// `getMountedRoutes()` is asserted to be a SUBSET of it, so a registration +// the port saw and the table did not would fail the derivation, not hide; +// - the declared ADR-0121 endpoints the metadata service matches, which the +// transport serves from its fallback seam and so no route table can list. +// Each is asserted to resolve through `matchEndpoint` to itself. +// +// ## The boot, and why each extra is on it +// +// `bootShowcase` composes what `objectstack serve` composes from the showcase's +// configuration, minus the two process decisions `bootStack` documents it +// leaves to the caller. This file adds back the ones a deployment has: +// +// - `MetadataPlugin` over a build-shaped artifact — `createStandaloneStack` +// composes it unconditionally, and without it no declared endpoint routes +// (classes 4 and 5 live there). Because `bootStack` runs the development +// posture, it also mounts the dev-only metadata HMR stream, which the +// classification therefore covers on a measured route; +// - `MCPServerPlugin` — `serve` mounts MCP by default (`OS_MCP_SERVER_ENABLED` +// opts out), and D2 names MCP as outside the guest model. +// +// Pinyin search, the other process decision, mounts no route. +// +// ## The fixtures, and why they are not the showcase's own +// +// The showcase declares no `authRequired: false` endpoint and one book, which +// is public. Classes 4 and 5 and the negative half of class 3 need subjects it +// does not have, so the configuration booted is the showcase plus three inline +// declarations: one anonymous `object_operation` endpoint, one anonymous `flow` +// endpoint over a flow with no data step, and one `org`-audience book. The +// showcase's own two declared endpoints are `authRequired: true` and of the +// same two types, so they are the negative controls of classes 4 and 5 as they +// ship. Every other subject (the public form, the shareable brief, the public +// book, an uploaded file) is the showcase's own or is created here, signed in, +// before the anonymous request. +// +// ## Disclosure +// +// Classes, positions and functions only (#22146). The assertions are +// class-level; a failure names the route pattern and the status it answered, +// which is what fixing it needs, and never a body. +// +// Not eligible for the shared showcase project: it boots a derived +// configuration with its own plugins. + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { MetadataPlugin } from '@objectstack/metadata'; +import { MCPServerPlugin } from '@objectstack/mcp'; +import showcaseStack from '@objectstack/example-showcase'; +import type { ApiEndpoint } from '@objectstack/spec/api'; +import type { Flow } from '@objectstack/spec/automation'; + +import { bootShowcase } from './showcase-boot.js'; +import { writeBuildShapedArtifact } from './build-shaped-artifact.js'; +import { + DOOR_CLASSES, + EVERYTHING_ELSE, + MIDDLEWARE, + OUTSIDE_FAMILIES, + classify, + probePathOf, + type SweepAnswer, + type SweepClass, + type SweptRoute, +} from './anonymous-door-sweep.classification.js'; + +const SYS = { isSystem: true } as const; +/** ADR-0121 D6: an anonymous endpoint publishes only with an armed budget. */ +const ARMED = { enabled: true, windowMs: 60_000, maxRequests: 1_000 }; + +// ── The three inline declarations (see the header) ─────────────────────────── + +/** start → assignment → end, under `runAs: 'user'`: a flow that touches no data. */ +const EchoFlow = { + name: 'anon_sweep_echo', + label: 'Anonymous sweep echo', + type: 'autolaunched', + runAs: 'user', + variables: [{ name: 'answer', type: 'text', isOutput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'assign', type: 'assignment', label: 'Answer', config: { assignments: [{ variable: 'answer', value: 'served' }] } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'assign' }, + { id: 'e2', source: 'assign', target: 'end' }, + ], +} as unknown as Flow; + +const GUEST_FIND_PATH = '/api/v1/apps/showcase/anon-sweep/tasks'; +const GUEST_FLOW_PATH = '/api/v1/apps/showcase/anon-sweep/echo'; +/** The showcase's own session-gated twins of the two fixtures (classes 4 and 5's negative controls). */ +const GATED_FIND_PATH = '/api/v1/apps/showcase/tasks'; +const GATED_FLOW_PATH = '/api/v1/apps/showcase/inquiries/purge'; + +const FIXTURE_APIS = [ + { + name: 'anon_sweep_guest_find', path: GUEST_FIND_PATH, method: 'GET', + summary: 'Guest find', description: 'ADR-0138 D2 class 4 fixture: an anonymous object_operation door.', + type: 'object_operation', objectParams: { object: 'showcase_task', operation: 'find' }, + authRequired: false, rateLimit: ARMED, + }, + { + name: 'anon_sweep_guest_flow', path: GUEST_FLOW_PATH, method: 'POST', + summary: 'Guest flow', description: 'ADR-0138 D2 class 5 fixture: an anonymous flow door.', + type: 'flow', target: EchoFlow.name, + authRequired: false, rateLimit: ARMED, + }, +] as unknown as ApiEndpoint[]; + +const PUBLIC_BOOK = 'showcase_manual'; +const ORG_BOOK = 'anon_sweep_org_manual'; +const OrgBook = { + name: ORG_BOOK, + label: 'Anonymous sweep org manual', + slug: 'anon-sweep-org-manual', + audience: 'org', + groups: [{ key: 'start', label: 'Start', order: 1, pages: [] }], +}; + +const showcase = showcaseStack as unknown as Record; +const SWEPT = { + ...(showcaseStack as Record), + apis: [...(showcase.apis ?? []), ...FIXTURE_APIS], + flows: [...(showcase.flows ?? []), EchoFlow], + books: [...(showcase.books ?? []), OrgBook], +}; + +// ── Probing ───────────────────────────────────────────────────────────────── + +/** One request, judged later. A request that never settles fails here, loudly. */ +async function send(stack: VerifyStack, method: string, path: string, init: RequestInit = {}): Promise { + const headers: Record = { 'content-type': 'application/json', ...(init.headers as Record) }; + const withBody = method !== 'GET' && method !== 'HEAD'; + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${method} ${path} did not answer within 15s`)), 15_000); + }); + try { + const res = await Promise.race([ + stack.raw(path, { ...init, method, headers, ...(withBody && init.body === undefined ? { body: '{}' } : {}) }), + timeout, + ]); + const contentType = res.headers.get('content-type') ?? ''; + if (contentType.includes('text/event-stream')) { + // A stream never ends on its own; its status and type are the answer. + await res.body?.cancel(); + return { status: res.status, contentType }; + } + const text = await res.text(); + if (!contentType.includes('json')) return { status: res.status, contentType }; + let json: Record | undefined; + try { + const parsed: unknown = JSON.parse(text); + json = parsed && typeof parsed === 'object' ? (parsed as Record) : { value: parsed }; + } catch { + json = { unparsable: true }; + } + return { status: res.status, contentType, json }; + } finally { + clearTimeout(timer); + } +} + +const keyOf = (r: { method: string; pattern: string }) => `${r.method} ${r.pattern}`; + +interface Swept { + readonly route: SweptRoute; + readonly probePath: string; + readonly cls: SweepClass; + answer?: SweepAnswer; +} + +// ── The boot ──────────────────────────────────────────────────────────────── + +let stack: VerifyStack; +let tempDir: string; +let adminToken: string; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let ql: any; + +let portRoutes: ReadonlyArray<{ method: string; pattern: string }>; +let tableKeys: Set; +let population: Swept[]; + +/** The context each engine `find` on the class-4 object was handed. */ +const engineFinds: Array | undefined> = []; +/** The context each flow run was started with. */ +const flowContexts: Array> = []; + +beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), 'os-anon-sweep-')); + const artifactPath = join(tempDir, 'objectstack.json'); + writeBuildShapedArtifact(SWEPT, artifactPath); + stack = await bootShowcase( + { + extraPlugins: [ + new MetadataPlugin({ + rootDir: tempDir, + watch: false, + artifactWatch: false, + registerSystemObjects: false, + artifactSource: { mode: 'local-file', path: artifactPath }, + }), + new MCPServerPlugin(), + ], + }, + SWEPT, + ); + adminToken = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + + // ── The population, read off the running server ── + const http = await stack.kernel.getServiceAsync<{ + getMountedRoutes?(): ReadonlyArray<{ method: string; pattern: string }>; + getRawApp?(): { routes?: Array<{ method?: string; path?: string }> }; + }>('http-server'); + expect(typeof http.getMountedRoutes, 'the booted http-server exposes no getMountedRoutes(): the sweep cannot read the port and must not pass').toBe('function'); + expect(typeof http.getRawApp, 'the booted http-server exposes no getRawApp(): the raw-app mounts are unreadable and must not pass').toBe('function'); + portRoutes = http.getMountedRoutes!(); + const portKeys = new Set(portRoutes.map(keyOf)); + const table = (http.getRawApp!().routes ?? []).filter( + (r): r is { method: string; path: string } => typeof r?.method === 'string' && typeof r?.path === 'string', + ); + tableKeys = new Set(table.map((r) => `${r.method.toUpperCase()} ${r.path}`)); + + const routes: SweptRoute[] = [...tableKeys].map((key) => { + const sp = key.indexOf(' '); + return { method: key.slice(0, sp), pattern: key.slice(sp + 1), source: portKeys.has(key) ? 'port' : 'raw' }; + }); + + const metadata = await stack.kernel.getServiceAsync<{ + list(type: string): Promise; + matchEndpoint?(q: { path: string; method: string }): Promise<{ endpoint: { name: string } } | undefined>; + }>('metadata'); + for (const item of (await metadata.list('api')) as Array>) { + routes.push({ + method: String(item.method).toUpperCase(), + pattern: String(item.path), + source: 'declared', + endpoint: { type: item.type as string | undefined, authRequired: item.authRequired as boolean | undefined }, + }); + } + + population = routes + .sort((a, b) => keyOf(a).localeCompare(keyOf(b))) + .map((route) => { + const probePath = route.source === 'declared' ? route.pattern : probePathOf(route.pattern); + return { route, probePath, cls: classify(route, probePath) }; + }); + + // ── Witnesses for what classes 4 and 5 run as ── + const find = ql.find.bind(ql); + vi.spyOn(ql, 'find').mockImplementation(async (...args: any[]) => { + if (args[0] === 'showcase_task') engineFinds.push(args[1]?.context); + return find(...args); + }); + const automation = stack.kernel.getService('automation') as { execute(n: string, c: unknown): Promise }; + const execute = automation.execute.bind(automation); + vi.spyOn(automation, 'execute').mockImplementation(async (name: string, context: any) => { + if (name === EchoFlow.name) flowContexts.push(context); + return execute(name, context); + }); +}, 240_000); + +afterAll(async () => { + vi.restoreAllMocks(); + await stack?.stop(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); +}); + +const anon = (path: string, init: RequestInit = {}) => + stack.raw(path, { ...init, headers: { 'content-type': 'application/json', ...(init.headers as Record) } }); + +// ── 0. The derivation reads a real table ──────────────────────────────────── + +describe('ADR-0138 D2 anonymous sweep: the population is read off the running server', () => { + it('reads a non-trivial table from all three sources', () => { + const by = (s: SweptRoute['source']) => population.filter((p) => p.route.source === s).length; + expect(by('port'), 'the port table is empty: every assertion below would be vacuous').toBeGreaterThan(100); + expect(by('raw'), 'no raw-app mount was read: the framework table did not resolve').toBeGreaterThan(10); + expect(by('declared'), 'no declared endpoint was read: the fallback seam is unobserved').toBeGreaterThanOrEqual(4); + }); + + it('the port table is a subset of the framework table, so no source hides a registration', () => { + const missing = portRoutes.map(keyOf).filter((k) => !tableKeys.has(k)); + expect(missing, 'registered through the port but absent from the framework table').toEqual([]); + }); + + it('carries one anchor from each source, and every declared endpoint resolves to itself', async () => { + const keys = new Set(population.map((p) => keyOf(p.route))); + for (const anchor of [ + 'GET /api/v1/meta/:type', // port + 'POST /api/v1/approvals/act', // raw + 'ALL /api/v1/auth/*', // raw: the auth catch-all + `GET ${GUEST_FIND_PATH}`, // declared + `POST ${GUEST_FLOW_PATH}`, // declared + ]) { + expect(keys.has(anchor), `the population lost an anchor: ${anchor}`).toBe(true); + } + const metadata = await stack.kernel.getServiceAsync<{ + matchEndpoint(q: { path: string; method: string }): Promise<{ endpoint: { path: string } } | undefined>; + }>('metadata'); + for (const p of population.filter((x) => x.route.source === 'declared')) { + const match = await metadata.matchEndpoint({ path: p.route.pattern, method: p.route.method }); + expect(match?.endpoint.path, `${keyOf(p.route)} is listed but the fallback seam does not match it`).toBe(p.route.pattern); + } + }); +}); + +// ── 1–6. Each door class, on a real subject: admitted, and refused ────────── + +describe('ADR-0138 D2 class 1: the public form doors', () => { + it('admit an anonymous resolve and submit of a form that declares anonymous sharing', async () => { + const resolve = await send(stack, 'GET', '/api/v1/forms/contact-us'); + expect(resolve.status).toBe(200); + expect(resolve.json?.object, 'the resolve serves the declared form').toBe('showcase_inquiry'); + + const submit = await send(stack, 'POST', '/api/v1/forms/contact-us/submit', { + body: JSON.stringify({ name: 'Anon Sweep', email: 'anon.sweep@example.com', company: 'Sweep Co', message: 'class 1' }), + }); + expect(submit.status, 'the submit is admitted').toBeGreaterThanOrEqual(200); + expect(submit.status).toBeLessThan(300); + expect(Object.keys(submit.json ?? {}), 'the submit answers the created id and nothing it stored').toEqual(['id']); + }); + + it('refuse a slug no form declares', async () => { + const res = await send(stack, 'GET', '/api/v1/forms/anon-sweep-undeclared'); + expect(DOOR_CLASSES[0]!.expect!(res), 'an undeclared form is no door').toBeUndefined(); + }); +}); + +describe('ADR-0138 D2 class 2: share links', () => { + it('admit the capability token to its one record, and refuse it once revoked', async () => { + const brief = await ql.findOne('showcase_client_brief', { + where: { title: 'Northwind — Website Relaunch brief' }, + context: SYS, + }); + expect(brief?.id, 'the seeded published brief').toBeTruthy(); + const minted = await stack.apiAs(adminToken, 'POST', '/share-links', { object: 'showcase_client_brief', recordId: brief.id }); + expect(minted.status, 'minted signed in').toBe(201); + const link = ((await minted.json()) as { data: { id: string; token: string } }).data; + + const resolved = await send(stack, 'GET', `/api/v1/share-links/${link.token}/resolve`); + expect(resolved.status).toBe(200); + const served = resolved.json?.data as { record?: { id?: unknown } } | undefined; + expect(served?.record?.id, 'the token serves the one record it names').toBe(brief.id); + + const revoked = await stack.apiAs(adminToken, 'DELETE', `/share-links/${link.id}`); + expect(revoked.status, 'revoked signed in').toBeLessThan(300); + const after = await send(stack, 'GET', `/api/v1/share-links/${link.token}/resolve`); + expect(DOOR_CLASSES[1]!.expect!(after), 'a revoked token is no door').toBeUndefined(); + }); +}); + +describe('ADR-0138 D2 class 3: the public book and doc metadata reads', () => { + it('admit an anonymous read of the public book and its tree', async () => { + const book = await send(stack, 'GET', `/api/v1/meta/book/${PUBLIC_BOOK}`); + expect(book.status, 'the public book').toBe(200); + expect(JSON.stringify(book.json)).toContain(PUBLIC_BOOK); + const tree = await send(stack, 'GET', `/api/v1/meta/book/${PUBLIC_BOOK}/tree`); + expect(tree.status, 'the public book tree').toBe(200); + }); + + it('refuse a book that is not public, and any write to the public one, with 401', async () => { + // The fixture book is there for a signed-in reader: the refusal is the audience gate's. + const signedIn = await stack.apiAs(adminToken, 'GET', `/meta/book/${ORG_BOOK}`); + expect(signedIn.status, 'the org book exists for a signed-in reader').toBe(200); + expect(EVERYTHING_ELSE(await send(stack, 'GET', `/api/v1/meta/book/${ORG_BOOK}`)), 'the org book').toBeUndefined(); + expect( + EVERYTHING_ELSE(await send(stack, 'PUT', `/api/v1/meta/book/${PUBLIC_BOOK}`, { body: JSON.stringify({ name: PUBLIC_BOOK }) })), + 'a write to the public book', + ).toBeUndefined(); + }); + + it('rides the shared /meta read routes, every one of them in the population', () => { + const keys = new Set(population.map((p) => keyOf(p.route))); + const class3 = DOOR_CLASSES.find((d) => d.id === 3)!; + for (const route of class3.ridesOn!) expect(keys.has(route), `class 3 rides ${route}`).toBe(true); + }); +}); + +describe('ADR-0138 D2 class 4: authRequired: false object_operation endpoints', () => { + it('admit the guest envelope, which holds no grant and is denied (D3 empty state)', async () => { + engineFinds.length = 0; + const res = await send(stack, 'GET', GUEST_FIND_PATH); + expect(DOOR_CLASSES[3]!.expect!(res)).toBeUndefined(); + expect(engineFinds, 'the engine is asked once').toHaveLength(1); + expect(engineFinds[0]).toMatchObject({ principalKind: 'guest', positions: ['guest'], isSystem: false }); + expect(engineFinds[0]).not.toHaveProperty('userId'); + }); + + it('refuse the session-gated sibling of the same type with 401', async () => { + expect(EVERYTHING_ELSE(await send(stack, 'GET', GATED_FIND_PATH))).toBeUndefined(); + }); +}); + +describe('ADR-0138 D2 class 5: authRequired: false flow endpoints', () => { + it('admit the guest envelope at the door and serve the flow output only', async () => { + flowContexts.length = 0; + const res = await send(stack, 'POST', GUEST_FLOW_PATH, { body: JSON.stringify({}) }); + expect(res.status).toBe(200); + const run = res.json?.data as { output?: unknown } | undefined; + expect(run?.output, 'the flow output, and nothing else').toEqual({ answer: 'served' }); + expect(flowContexts, 'the flow runs once').toHaveLength(1); + expect(flowContexts[0]!.positions).toEqual(['guest']); + expect(flowContexts[0]).not.toHaveProperty('userId'); + expect(flowContexts[0]).not.toHaveProperty('isSystem'); + }); + + it('refuse the session-gated sibling of the same type with 401', async () => { + expect(EVERYTHING_ELSE(await send(stack, 'POST', GATED_FLOW_PATH))).toBeUndefined(); + }); +}); + +describe("ADR-0138 D2 class 6: the acl: 'public_read' file download", () => { + const BYTES = 'anon-sweep-bytes'; + const toPath = (url: string): string => url.replace(/^https?:\/\/[^/]+/, ''); + + /** The real three-step upload, signed in, of a file no record claims. */ + const upload = async (name: string): Promise => { + const presign = await stack.apiAs(adminToken, 'POST', '/storage/upload/presigned', { + filename: name, mimeType: 'text/plain', size: BYTES.length, + }); + expect(presign.status, 'presign').toBe(200); + const { data } = (await presign.json()) as { data: { uploadUrl: string; fileId: string; headers?: Record } }; + const put = await stack.raw(toPath(String(data.uploadUrl)), { + method: 'PUT', headers: data.headers ?? { 'content-type': 'text/plain' }, body: BYTES, + }); + expect(put.status, 'upload').toBeLessThan(300); + const complete = await stack.apiAs(adminToken, 'POST', '/storage/upload/complete', { fileId: data.fileId }); + expect(complete.status, 'complete').toBe(200); + return String(data.fileId); + }; + + it("serve an acl: 'public_read' file's bytes anonymously, and refuse a private one with 401", async () => { + const opened = await upload('anon-sweep-public.txt'); + const closed = await upload('anon-sweep-private.txt'); + await ql.update('sys_file', { acl: 'public_read' }, { where: { id: opened }, context: SYS }); + + const redirect = await anon(`/api/v1/storage/files/${opened}`, { redirect: 'manual' } as RequestInit); + expect(redirect.status, 'the download door admits the declared file').toBe(302); + const location = redirect.headers.get('location'); + expect(location, 'a 302 with a Location').toBeTruthy(); + const bytes = await stack.raw(toPath(String(location))); + expect(bytes.status).toBe(200); + expect(await bytes.text(), "the file's own bytes, nothing else").toBe(BYTES); + expect((await send(stack, 'GET', `/api/v1/storage/files/${opened}/url`)).status, 'the URL door admits it too').toBe(200); + + for (const door of [`/api/v1/storage/files/${closed}`, `/api/v1/storage/files/${closed}/url`]) { + const res = await send(stack, 'GET', door, { redirect: 'manual' } as RequestInit); + expect(res.status, `a private file with no scope and no field owner, at ${door.endsWith('/url') ? 'the URL door' : 'the download door'}`).toBe(401); + } + }); +}); + +// ── 7. The sweep: one anonymous request per route ─────────────────────────── +// +// Last, because the population holds the dev HMR door, whose POST reloads the +// artifact, and the class legs above should not race that reload. + +describe('ADR-0138 D2 anonymous sweep: one unauthenticated request per registered route', () => { + beforeAll(async () => { + // Middleware answers no request of its own, and the HMR reload goes last. + const order = (p: Swept) => (p.cls.kind === 'outside' && p.cls.family.id === 'dev-metadata-hmr' ? 1 : 0); + for (const p of [...population].sort((a, b) => order(a) - order(b))) { + if (p.cls.kind === 'middleware') continue; + p.answer = await send(stack, p.route.method === 'ALL' ? 'GET' : p.route.method, p.probePath); + } + }, 300_000); + + const failures = (pick: (p: Swept) => string | undefined): string[] => + population.flatMap((p) => { + const why = pick(p); + return why ? [`${keyOf(p.route)} (${p.route.source}) — ${why}`] : []; + }); + + it('every route but middleware got exactly one answer', () => { + const unanswered = population.filter((p) => p.cls.kind !== 'middleware' && p.answer === undefined); + expect(unanswered.map((p) => keyOf(p.route))).toEqual([]); + }); + + it('the everything-else class answers 401 throughout, with nothing served', () => { + const swept = population.filter((p) => p.cls.kind === 'everything-else'); + expect(swept.length, 'the everything-else class is the bulk of the table').toBeGreaterThan(100); + const red = failures((p) => (p.cls.kind === 'everything-else' ? EVERYTHING_ELSE(p.answer!) : undefined)); + expect(red, `\nRoutes in no door class and outside no family, which ADR-0138 D2 says answer 401:\n${red.join('\n')}\n`).toEqual([]); + }); + + it('every door-class route answers its class, asked about no declared subject', () => { + const red = failures((p) => (p.cls.kind === 'door' && p.cls.door.expect ? p.cls.door.expect(p.answer!) : undefined)); + expect(red, `\n${red.join('\n')}\n`).toEqual([]); + }); + + it('the outside families answer as D2 states, where it states an answer', () => { + const red = failures((p) => (p.cls.kind === 'outside' && p.cls.family.expect ? p.cls.family.expect(p.answer!) : undefined)); + expect(red, `\n${red.join('\n')}\n`).toEqual([]); + }); + + it('every row of the classification claims a route on this boot: none is stale', () => { + const claimed = new Set(); + for (const p of population) { + if (p.cls.kind === 'door') claimed.add(`door ${p.cls.door.id}`); + if (p.cls.kind === 'outside') claimed.add(`outside ${p.cls.family.id}`); + if (p.cls.kind === 'middleware') claimed.add(`middleware ${keyOf(p.route)}`); + } + const stale = [ + ...DOOR_CLASSES.filter((d) => d.match && !claimed.has(`door ${d.id}`)).map((d) => `door class ${d.id}`), + ...OUTSIDE_FAMILIES.filter((f) => !claimed.has(`outside ${f.id}`)).map((f) => `outside family ${f.id}`), + ...MIDDLEWARE.filter((m) => !claimed.has(`middleware ${m.key}`)).map((m) => `middleware ${m.key}`), + ]; + expect(stale, 'a classification row that matches nothing on this boot is a row nobody can trust').toEqual([]); + }); +}); From 0b74fadb69403e562a55ae2bb03e668b24b70bdf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 02:10:56 +0000 Subject: [PATCH 2/3] test(dogfood): the anonymous sweep asserts class 3 on docs, and its header says what a red run means Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H --- .../test/anonymous-door-sweep.dogfood.test.ts | 86 +++++++++++++------ 1 file changed, 59 insertions(+), 27 deletions(-) diff --git a/packages/qa/dogfood/test/anonymous-door-sweep.dogfood.test.ts b/packages/qa/dogfood/test/anonymous-door-sweep.dogfood.test.ts index 5ba4c3e1880..50c96f86803 100644 --- a/packages/qa/dogfood/test/anonymous-door-sweep.dogfood.test.ts +++ b/packages/qa/dogfood/test/anonymous-door-sweep.dogfood.test.ts @@ -25,44 +25,58 @@ // // ## Where the population comes from — three sources, each guarded // -// - the framework's own route table (`getRawApp().routes`): everything the -// router will route, the raw-app mounts included (`plugin-auth`'s -// catch-all and well-known documents, the approval pages, the inbound hook, -// the HMR stream, the runtime-config reads). The port's -// `getMountedRoutes()` is asserted to be a SUBSET of it, so a registration -// the port saw and the table did not would fail the derivation, not hide; -// - the declared ADR-0121 endpoints the metadata service matches, which the -// transport serves from its fallback seam and so no route table can list. -// Each is asserted to resolve through `matchEndpoint` to itself. +// - `port`: `IHttpServer.getMountedRoutes()`, every route registered through +// the HTTP-server contract; +// - `raw`: the framework's own route table (`getRawApp().routes`), which +// holds the port's routes AND the mounts made on the raw app handle, +// outside the port by construction (`plugin-auth`'s catch-all and +// well-known documents, the approval pages, the inbound hook, the HMR +// stream, the runtime-config reads). The port table is asserted to be a +// SUBSET of it, so a registration one saw and the other did not fails the +// derivation instead of hiding from it; +// - `declared`: the ADR-0121 endpoints the metadata service matches, which +// the transport serves from its fallback seam, so no route table lists +// them. Each is asserted to resolve through `matchEndpoint` to itself. // // ## The boot, and why each extra is on it // // `bootShowcase` composes what `objectstack serve` composes from the showcase's -// configuration, minus the two process decisions `bootStack` documents it -// leaves to the caller. This file adds back the ones a deployment has: +// configuration, with three exceptions `bootStack` documents. This file adds +// back the two a deployment of the showcase has a route for: // -// - `MetadataPlugin` over a build-shaped artifact — `createStandaloneStack` -// composes it unconditionally, and without it no declared endpoint routes -// (classes 4 and 5 live there). Because `bootStack` runs the development -// posture, it also mounts the dev-only metadata HMR stream, which the -// classification therefore covers on a measured route; -// - `MCPServerPlugin` — `serve` mounts MCP by default (`OS_MCP_SERVER_ENABLED` +// - `MetadataPlugin` over a build-shaped artifact. The lean harness reads +// metadata off the configuration and composes none, while +// `createStandaloneStack` composes it unconditionally, and without it no +// declared endpoint routes (classes 4 and 5 live there). Because +// `bootStack` runs the development posture, it also mounts the dev-only +// metadata HMR stream, so the classification covers that door on a +// measured route; +// - `MCPServerPlugin`. `serve` mounts MCP by default (`OS_MCP_SERVER_ENABLED` // opts out), and D2 names MCP as outside the guest model. // -// Pinyin search, the other process decision, mounts no route. +// Pinyin search, the third, mounts no route. // // ## The fixtures, and why they are not the showcase's own // // The showcase declares no `authRequired: false` endpoint and one book, which // is public. Classes 4 and 5 and the negative half of class 3 need subjects it -// does not have, so the configuration booted is the showcase plus three inline +// does not have, so the configuration booted is the showcase plus inline // declarations: one anonymous `object_operation` endpoint, one anonymous `flow` -// endpoint over a flow with no data step, and one `org`-audience book. The -// showcase's own two declared endpoints are `authRequired: true` and of the -// same two types, so they are the negative controls of classes 4 and 5 as they -// ship. Every other subject (the public form, the shareable brief, the public -// book, an uploaded file) is the showcase's own or is created here, signed in, -// before the anonymous request. +// endpoint over a flow with no data step, one `org`-audience book, and two docs +// (one the public book claims by its own include rule, one only the org book +// claims). The showcase's own two declared endpoints are `authRequired: true` +// and of the same two types, so they are the negative controls of classes 4 +// and 5 as they ship. Every other subject (the public form, the shareable +// brief, the public book, an uploaded file) is the showcase's own or is +// created here, signed in, before the anonymous request. +// +// ## What a red run means +// +// A red everything-else case is a finding about the deployment: a surface that +// serves an anonymous caller without the 401 floor D2 says every surface off +// the list has. ⛔ It is never fixed here. A route moves into a door class or +// an outside family only by an amendment of ADR-0138, and the classification +// is edited after that amendment, never instead of it. // // ## Disclosure // @@ -103,7 +117,7 @@ const SYS = { isSystem: true } as const; /** ADR-0121 D6: an anonymous endpoint publishes only with an armed budget. */ const ARMED = { enabled: true, windowMs: 60_000, maxRequests: 1_000 }; -// ── The three inline declarations (see the header) ─────────────────────────── +// ── The inline declarations (see the header) ──────────────────────────────── /** start → assignment → end, under `runAs: 'user'`: a flow that touches no data. */ const EchoFlow = { @@ -146,13 +160,21 @@ const FIXTURE_APIS = [ const PUBLIC_BOOK = 'showcase_manual'; const ORG_BOOK = 'anon_sweep_org_manual'; +/** Joins the public book by its own derived rule (`include: 'showcase_*_guide'`). */ +const PUBLIC_DOC = 'showcase_anon_sweep_guide'; +/** Claimed by the org book alone. */ +const ORG_DOC = 'anon_sweep_org_page'; const OrgBook = { name: ORG_BOOK, label: 'Anonymous sweep org manual', slug: 'anon-sweep-org-manual', audience: 'org', - groups: [{ key: 'start', label: 'Start', order: 1, pages: [] }], + groups: [{ key: 'start', label: 'Start', order: 1, pages: [ORG_DOC] }], }; +const FIXTURE_DOCS = [ + { name: PUBLIC_DOC, label: 'Anonymous sweep guide', content: '# Anonymous sweep guide\n\nA doc the public book claims.' }, + { name: ORG_DOC, label: 'Anonymous sweep org page', content: '# Anonymous sweep org page\n\nA doc only the org book claims.' }, +]; const showcase = showcaseStack as unknown as Record; const SWEPT = { @@ -160,6 +182,7 @@ const SWEPT = { apis: [...(showcase.apis ?? []), ...FIXTURE_APIS], flows: [...(showcase.flows ?? []), EchoFlow], books: [...(showcase.books ?? []), OrgBook], + docs: [...(showcase.docs ?? []), ...FIXTURE_DOCS], }; // ── Probing ───────────────────────────────────────────────────────────────── @@ -398,11 +421,20 @@ describe('ADR-0138 D2 class 3: the public book and doc metadata reads', () => { expect(tree.status, 'the public book tree').toBe(200); }); + it('admit an anonymous read of a doc the public book claims', async () => { + const doc = await send(stack, 'GET', `/api/v1/meta/doc/${PUBLIC_DOC}`); + expect(doc.status, 'a doc of the public book').toBe(200); + expect(JSON.stringify(doc.json)).toContain(PUBLIC_DOC); + }); + it('refuse a book that is not public, and any write to the public one, with 401', async () => { // The fixture book is there for a signed-in reader: the refusal is the audience gate's. const signedIn = await stack.apiAs(adminToken, 'GET', `/meta/book/${ORG_BOOK}`); expect(signedIn.status, 'the org book exists for a signed-in reader').toBe(200); expect(EVERYTHING_ELSE(await send(stack, 'GET', `/api/v1/meta/book/${ORG_BOOK}`)), 'the org book').toBeUndefined(); + const orgDoc = await stack.apiAs(adminToken, 'GET', `/meta/doc/${ORG_DOC}`); + expect(orgDoc.status, 'the org doc exists for a signed-in reader').toBe(200); + expect(EVERYTHING_ELSE(await send(stack, 'GET', `/api/v1/meta/doc/${ORG_DOC}`)), 'a doc only the org book claims').toBeUndefined(); expect( EVERYTHING_ELSE(await send(stack, 'PUT', `/api/v1/meta/book/${PUBLIC_BOOK}`, { body: JSON.stringify({ name: PUBLIC_BOOK }) })), 'a write to the public book', From 4bdd92c65eba2faca7fd9662166fcf9ad0826f93 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:15:03 +0000 Subject: [PATCH 3/3] test(dogfood): the anonymous sweep reports red routes by class and mount family, with route detail behind a local-only switch Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H --- .../test/anonymous-door-sweep.dogfood.test.ts | 102 +++++++++++++++--- 1 file changed, 89 insertions(+), 13 deletions(-) diff --git a/packages/qa/dogfood/test/anonymous-door-sweep.dogfood.test.ts b/packages/qa/dogfood/test/anonymous-door-sweep.dogfood.test.ts index 50c96f86803..a0eedd75035 100644 --- a/packages/qa/dogfood/test/anonymous-door-sweep.dogfood.test.ts +++ b/packages/qa/dogfood/test/anonymous-door-sweep.dogfood.test.ts @@ -78,11 +78,31 @@ // an outside family only by an amendment of ADR-0138, and the classification // is edited after that amendment, never instead of it. // -// ## Disclosure +// ## Disclosure: what a red run prints, and the one local switch // -// Classes, positions and functions only (#22146). The assertions are -// class-level; a failure names the route pattern and the status it answered, -// which is what fixing it needs, and never a body. +// Classes, positions and functions only (#22146; ruling 6134095444: "no +// route-level readings go on GitHub"). This suite runs on `main` with the +// dogfood battery, so whatever a red case prints lands in a public CI log. +// So a red sweep case prints, BY DEFAULT, one line per class and mount family: +// the class (a door class, an outside family or the everything-else class), +// the mount family the route table names (the pattern's leading segments; the +// table records patterns, not registrars), the source, and the COUNT of red +// routes in it. ⛔ It never pairs a route with the answer it got, and it never +// prints a body. The judgement is unchanged: the same routes are judged, and a +// case is red exactly when one of them misses its class. +// +// Route-level detail (each red route's pattern and why it missed its class) is +// for a developer's own machine only, behind `OS_TEST_ANON_SWEEP_ROUTE_DETAIL`, +// which is off by default: +// +// OS_TEST_ANON_SWEEP_ROUTE_DETAIL=1 pnpm --filter @objectstack/dogfood \ +// exec vitest run test/anonymous-door-sweep.dogfood.test.ts +// +// ⛔ Never set it in CI or in a workflow. Two belts keep it local: through +// `turbo run test` (the path CI takes) the variable is never forwarded, +// because turbo's strict env mode hands a task only what its `env` declares +// and `@objectstack/dogfood#test` declares `OS_TEST_TIERS` and `OS_TEST_SHARD` +// alone; and the switch is ignored whenever `CI` is set. // // Not eligible for the shared showcase project: it boots a derived // configuration with its own plugins. @@ -193,7 +213,11 @@ async function send(stack: VerifyStack, method: string, path: string, init: Requ const withBody = method !== 'GET' && method !== 'HEAD'; let timer: ReturnType | undefined; const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`${method} ${path} did not answer within 15s`)), 15_000); + // Path-free unless the local switch is on: a hang is an answer too (header, Disclosure). + timer = setTimeout( + () => reject(new Error(`a request did not answer within 15s${ROUTE_DETAIL ? `: ${method} ${path}` : ''}`)), + 15_000, + ); }); try { const res = await Promise.race([ @@ -223,6 +247,31 @@ async function send(stack: VerifyStack, method: string, path: string, init: Requ const keyOf = (r: { method: string; pattern: string }) => `${r.method} ${r.pattern}`; +/** + * The local-only switch for route-level failure detail (header, Disclosure). + * Off by default, and ignored whenever `CI` is set. + */ +const ROUTE_DETAIL = process.env.OS_TEST_ANON_SWEEP_ROUTE_DETAIL === '1' && !process.env.CI; + +/** + * The mount family a route belongs to: the pattern's leading segments past the + * API base, which is as much as the route table can say about who registered + * it. A declared endpoint is in no route table, so its family is its kind. + */ +function mountFamilyOf(route: SweptRoute): string { + if (route.source === 'declared') { + return `declared ${route.endpoint?.type ?? 'unknown-type'} endpoints (authRequired: ${String(route.endpoint?.authRequired)})`; + } + const segs = route.pattern.split('/').filter((x) => x !== ''); + const base = segs[0] === 'api' ? (segs[1] === 'v1' ? 2 : 1) : 0; + const head = segs[base]; + const kept = head === undefined || head.startsWith(':') || head === '*' ? segs.slice(0, base) : segs.slice(0, base + 1); + return `/${kept.join('/')}`; +} + +const classLabelOf = (cls: SweepClass): string => + cls.kind === 'door' ? `door class ${cls.door.id}` : cls.kind === 'outside' ? `outside family ${cls.family.id}` : cls.kind; + interface Swept { readonly route: SweptRoute; readonly probePath: string; @@ -533,18 +582,42 @@ describe('ADR-0138 D2 anonymous sweep: one unauthenticated request per registere const order = (p: Swept) => (p.cls.kind === 'outside' && p.cls.family.id === 'dev-metadata-hmr' ? 1 : 0); for (const p of [...population].sort((a, b) => order(a) - order(b))) { if (p.cls.kind === 'middleware') continue; - p.answer = await send(stack, p.route.method === 'ALL' ? 'GET' : p.route.method, p.probePath); + try { + p.answer = await send(stack, p.route.method === 'ALL' ? 'GET' : p.route.method, p.probePath); + } catch { + // No answer is recorded as status 0: it misses every class and is + // counted, never thrown with its route (header, Disclosure). + p.answer = { status: 0, contentType: '' }; + } } }, 300_000); - const failures = (pick: (p: Swept) => string | undefined): string[] => - population.flatMap((p) => { + /** + * The red routes of one case, as that case prints them. By default one line + * per class and mount family with the COUNT of red routes in it; with the + * local switch, one line per route. Non-empty exactly when a route is red, + * so the switch changes what is printed and never what is judged. + */ + const failures = (pick: (p: Swept) => string | undefined): string[] => { + const red = population.flatMap((p) => { const why = pick(p); - return why ? [`${keyOf(p.route)} (${p.route.source}) — ${why}`] : []; + return why ? [{ p, why }] : []; }); + if (ROUTE_DETAIL) return red.map(({ p, why }) => `${keyOf(p.route)} (${p.route.source}) — ${why}`); + const counts = new Map(); + for (const { p } of red) { + const group = `${classLabelOf(p.cls)} · ${mountFamilyOf(p.route)} (${p.route.source})`; + counts.set(group, (counts.get(group) ?? 0) + 1); + } + return [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([group, n]) => `${group}: ${n} route(s) red`); + }; + + const DETAIL_HINT = ROUTE_DETAIL + ? '' + : 'Route-level detail is local only: set OS_TEST_ANON_SWEEP_ROUTE_DETAIL=1 and run this file directly (see its header).'; it('every route but middleware got exactly one answer', () => { - const unanswered = population.filter((p) => p.cls.kind !== 'middleware' && p.answer === undefined); + const unanswered = population.filter((p) => p.cls.kind !== 'middleware' && !(p.answer && p.answer.status > 0)); expect(unanswered.map((p) => keyOf(p.route))).toEqual([]); }); @@ -552,17 +625,20 @@ describe('ADR-0138 D2 anonymous sweep: one unauthenticated request per registere const swept = population.filter((p) => p.cls.kind === 'everything-else'); expect(swept.length, 'the everything-else class is the bulk of the table').toBeGreaterThan(100); const red = failures((p) => (p.cls.kind === 'everything-else' ? EVERYTHING_ELSE(p.answer!) : undefined)); - expect(red, `\nRoutes in no door class and outside no family, which ADR-0138 D2 says answer 401:\n${red.join('\n')}\n`).toEqual([]); + expect( + red, + `\nADR-0138 D2: the everything-else class (no door class, no outside family) answers 401.\n${red.join('\n')}\n${DETAIL_HINT}\n`, + ).toEqual([]); }); it('every door-class route answers its class, asked about no declared subject', () => { const red = failures((p) => (p.cls.kind === 'door' && p.cls.door.expect ? p.cls.door.expect(p.answer!) : undefined)); - expect(red, `\n${red.join('\n')}\n`).toEqual([]); + expect(red, `\nADR-0138 D2: a door-class route asked about no declared subject answers its class.\n${red.join('\n')}\n${DETAIL_HINT}\n`).toEqual([]); }); it('the outside families answer as D2 states, where it states an answer', () => { const red = failures((p) => (p.cls.kind === 'outside' && p.cls.family.expect ? p.cls.family.expect(p.answer!) : undefined)); - expect(red, `\n${red.join('\n')}\n`).toEqual([]); + expect(red, `\nADR-0138 D2: an outside family answers as D2 states, where it states an answer.\n${red.join('\n')}\n${DETAIL_HINT}\n`).toEqual([]); }); it('every row of the classification claims a route on this boot: none is stale', () => {