Skip to content

Commit 166a94f

Browse files
fix(rest): the API-description endpoints refuse an anonymous caller (#22430) (#22446)
Fixes #22430 Clause-②: no (narrowing) ## What changed `RestServer.registerOpenApiEndpoints` registers the API-description endpoints: the document and its viewer page, on every base the server mounts. Neither handler checked who was calling. Both now open with the package's existing anonymous-deny floor (`enforceAuth`, which asks `shouldDenyAnonymous` from `@objectstack/core`). The check runs first, before the bundled artifact is loaded or a protocol is resolved, so a refused request does no work. - An anonymous caller gets `401` with code `UNAUTHENTICATED`. The body is `ANONYMOUS_DENY_BODY`, byte-identical to the anonymous 401 the data routes answer. - A signed-in caller is served as before. - A session held by the ADR-0069 auth-policy gate (an expired password, enforced MFA) gets that gate's `403`, as on every other protected route. - No new error code, no spec key: `Clause-②: no`. This executes ruling `6074960686` on #22146, item 2, verbatim: > 2. **The API-description endpoints** refuse an anonymous caller (401). ## Measured first: a real boot, before and after `pnpm dev -- --fresh` (the showcase) on this branch, before the fix (base `b9222dc70`) and after it. Statuses only. "Gated" is a member created by the admin, still under the must-change-password policy, probed before the password change. "Member" is the same account after it. | endpoint | caller | before | after | |---|---|---|---| | the document, unscoped base | anonymous | 200 (served) | **401 `UNAUTHENTICATED`** | | the document, unscoped base | gated session | 200 (served) | 403 `PASSWORD_EXPIRED` | | the document, unscoped base | signed-in member | 200 | 200 | | the document, unscoped base | signed-in admin | 200 | 200 | | the viewer, unscoped base | anonymous | 200 (page) | **401 `UNAUTHENTICATED`** | | the viewer, unscoped base | gated session | 200 (page) | 403 `PASSWORD_EXPIRED` | | the viewer, unscoped base | signed-in member | 200 | 200 | | the viewer, unscoped base | signed-in admin | 200 | 200 | | either, environment-scoped base | every caller | 404 (not mounted) | 404 (not mounted) | | control: a data route | anonymous / gated / member | 401 / 403 / 200 | 401 / 403 / 200 | The environment-scoped base is registered only under project scoping, and the showcase does not enable it. So on this boot both bases answer the same way for the scoped twin: it is not mounted. The scoped twin runs the same handler closures. It is measured at the handler level in `rest-api-description-anonymous-deny.test.ts`, for both bases. Against the base `rest-server.ts`, all four anonymous cells answer 200, and the test turns red. ## The viewer carries the session (H4) The viewer is an HTML page whose script fetches the document from the browser. I measured it in Chromium with the real viewer bundle. The CDN the page names is unreachable from this container, so the browser was served the same package (`@scalar/api-reference` 1.73.1) from its npm tarball. - Before: anonymous navigation 200; the viewer fetched the document with no cookie and got 200. - After, anonymous: navigation 401; the viewer never runs. - After, signed in (cookie session): navigation 200; the viewer's fetch of the document carried the session cookie and got 200. A signed-in browser is served on both endpoints, as before. ## The ruling's confidence gap: an anonymous consumer in the tree? Not found. I searched objectstack at this branch, objectui `origin/main` `049012bf`, and the objectui pin `a58626c88` for anything that fetches the document or the viewer. - `@objectstack/client`, the CLI, `scripts/`, `.github/`, `examples/`, `apps/docs` (its reference pages are generated from the Zod schemas, not from the served document): zero hits. - objectui `apps/**` and `packages/**`, at both commits: zero fetches. The only `openapi.json` mentions are the `@objectstack/spec` package-export name in build tooling and its tests. The Console's `/docs` routes are its own book portal, not this viewer. - One reader of the served document exists in the tree: `showcase-declarative-endpoints.dogfood.test.ts`. It reads it with an admin token, so it is unaffected. - The platform checklist's route-parity item probes the document "as admin", so it is unaffected. - `/discovery` does not advertise the document's URL. A deployed public API portal outside this repository is NOT MEASURED. ## Translation-flip sweep Pins and prose that held the old meaning, all in this PR: - `packages/rest/src/openapi-builtin-paths.ts`: the coverage note listed these endpoints among "the routes that answer anonymously". It now names only the discovery routes and says the inherited document-level requirement is true of these two. A new case pins that neither operation carries its own `security`, and that the document states a requirement. - Four rest suites drove the document with no session and read a 200: `rest-openapi-route`, `rest-openapi-info-overlay`, `direct-mount-introspection` and `direct-mount-base-follows-apipath`. A fifth, `rest-endpoint-surfaces-served-only`, was already signed in. The four test the document's contents, so they now read it as a signed-in caller. The refusal itself is asserted in the new suite: status, `code`, the whole body, no document keys, no page, and no artifact load or protocol read. - `execctx-consumer-census.test.ts`: 66 to 68 sites, 45 to 47 bare. Both new sites are bare, with the shared floor on the next line. Its §3 drives them: an absent context gets 401 `UNAUTHENTICATED`, an entitled one clears the floor. - Authorization matrix: the `openapi` REST family moves from the shrink-only unclassified baseline (32 to 31) to a new `enforced` row, `anonymous-deny-api-description`. Its cited proof is `showcase-anonymous-deny-surfaces.dogfood.test.ts`, which now drives both endpoints on the booted showcase. Anonymous gets 401, the REST flat envelope, the whole `ANONYMOUS_DENY_BODY`, nothing served. A signed-in member gets 200 for the document and 200 for the viewer page. Both endpoints joined the envelope-family table. The population pin in `authz-conformance.test.ts` lists the family as classified. - The authz probe blind-spot census (`authz-probe-blind-spot.census.ts` and its test), re-measured from `deriveProbeFileCensus()` on the tree, as in the `/analytics` precedent: - `PROBE_TABLE` moves from `{ entries: 19, files: 14, keys: 17 }` to `{ 19, 14, 18 }`. The new row covers one more key; no probe and no file joined. - The `rest-server.ts` control `enforceAuth` moves from 56 to 58: the two handlers' call sites, with no prose mention. - Population, reach and blind spot re-derive unchanged at 71 / 19 / 52, and every other control is unchanged. Guarding a route is not reaching it with a key, so the blind spot does not shrink. - The test's count moves from 17 to 18 `covers` keys. The ledgers still mint 39 keys: 8 classified, 31 baselined. - The `rest-route-ledger.ts` row's note now says 2 families classified (metadata and openapi) and 16 baselined. ## Tests and gates (at `d1cc56281`) - Reverse verification, unit level: the new suite against the base `rest-server.ts`. 7 failed and 4 passed. The 4 anonymous cells read `expected 200 to be 401`. The signed-in controls passed. Restored to the HEAD blob, `git diff HEAD` empty. After the fix: 13 of 13. - Ablation through `dist/` (`scripts/ablation-replace.mjs` + `ablation-dist-preflight.mjs`): I removed only the document's floor, rebuilt `@objectstack/rest`, and confirmed the marker in both built files. The booted-showcase proof then failed exactly its two document-anonymous cases (2 failed / 65 passed); the viewer cases and the controls stayed green. Restored to the HEAD blob, rebuilt, and confirmed the marker absent from `dist/`. - `@objectstack/rest`, at `1d4ad614f`: the full suite gave 271 files, 5164 passed, 327 skipped; typecheck passed (tests are covered through `tsconfig.test.json`, 6 of 6 touched files). Nothing under `packages/rest` changed after that commit. - `@objectstack/dogfood`, the WHOLE suite, through the verify lock: 233 files (232 passed, 1 skipped), 1847 tests (1838 passed, 9 skipped), exit 0. It ran at `0c0b1f7e5`; the next commit, `d1cc56281`, changes only a string and a comment in the census note. At `d1cc56281`, `authz-probe-blind-spot.test.ts` + `authz-conformance.test.ts` passed 90 of 90. - `pnpm check:doc-authoring` exits 0 at `d1cc56281`. It was red at `0c0b1f7e5` on a tracker id inside the census note's string prose. The id now sits in an adjacent comment. - `dispatch-gates.mjs --commands` derives the same 69 commands at `d1cc56281`, and all 69 exited 0 there. Three gates read built output and first answered `PREREQUISITE NOT MET` (exit 3) in the fresh worktree: `check:dual-build-cjs-loads`, `check:dts-closure` and `check:sourcemap-no-sources-content` (the last two swept 63 and 60 packages). I built the eight packages that had no `dist/` (all turbo cache hits) and re-ran them: exit 0, sweeping 66, 71 and 68 packages. `--ran` reconciles 69 derived, 69 run, 0 not measured. - `pnpm lint` (the full run) exited 0 at `d1cc56281`. `@objectstack/dogfood` typecheck exited 0 at `d1cc56281`. ## Acceptance notes - **Operators who served the API description publicly:** readers must now sign in. Otherwise, publish a static copy of the document. The changeset says so. - **Not in this change, by the ruling's scope:** `/discovery` still answers anonymously, as before. On the environment-scoped twin, a signed-in caller is judged by the auth service of the environment the URL names. These handlers do not add the ownership comparison the UI-view route makes. I did not measure that, because the showcase mounts no scoped base. It is a question for whoever re-measures the everything-else class for ADR-0138. - **Observation:** the viewer page loads its script from a CDN with no version pinned, on the API's own origin. That was true before this change and is unchanged by it. - **Left as is:** the ledger rows for this family in `rest-route-ledger.ts` could carry the optional `authz: 'anonymous-deny-api-description'` field. I left that file alone because another hold covers its notes. --- _Generated by [Claude Code](https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c512c25 commit 166a94f

15 files changed

Lines changed: 444 additions & 46 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
fix(rest)!: the API-description endpoints refuse an anonymous caller
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A runtime authorization narrowing at the two API-description routes that RestServer.registerOpenApiEndpoints mounts (the document and its viewer page), not a metadata change. No spec key, export, option, config field, response field or stored shape is removed, renamed or re-shaped, and the document a signed-in caller receives is unchanged, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. What narrows is which callers the two routes serve: a caller with no session is now refused with the shared anonymous-deny 401, a session held by an auth policy gets that policy's 403, and a signed-in caller is served as before. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers these routes and this diff adds none (not registered / already-registered); and no published interface or type changes (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
`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.
14+
15+
- **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.
16+
- **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.
17+
- **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.
18+
19+
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.

‎packages/qa/dogfood/test/authz-conformance.matrix.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,16 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
284284
// which stays in the shrink-only baseline.
285285
covers: ['analytics:domains/analytics.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/analytics'],
286286
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.' },
287+
// #22430 — the API-description endpoints, under the ruling recorded on #22146
288+
// (the API-description endpoints refuse an anonymous caller with 401).
289+
{ id: 'anonymous-deny-api-description', summary: 'anonymous-deny on the API-description endpoints (the document and its viewer)', state: 'enforced',
290+
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',
291+
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts',
292+
// The REST family at ledger granularity. The family is exactly these two
293+
// routes and both handlers open with the floor, which is the property a
294+
// family key needs.
295+
covers: ['rest-family:rest-route-ledger.ts:openapi'],
296+
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.' },
287297

288298
// ── #2992 / ADR-0096 D4 — latent execution surfaces (pre-wiring identity
289299
// admission). Neither surface is reachable by a client today; these rows

‎packages/qa/dogfood/test/authz-conformance.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -977,6 +977,8 @@ describe('the ledger-sourced population and its baseline bite', () => {
977977
'dispatcher-domain:route-ledger.ts:/meta',
978978
'dispatcher-domain:route-ledger.ts:/packages',
979979
'rest-family:rest-route-ledger.ts:metadata',
980+
// [#22430] classified by `anonymous-deny-api-description`.
981+
'rest-family:rest-route-ledger.ts:openapi',
980982
]);
981983
// …and the two halves partition the population exactly: nothing minted by
982984
// a ledger probe is both, and nothing is neither.

‎packages/qa/dogfood/test/authz-ledger-population.baseline.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@
7070
export const LEDGER_POPULATION_BASELINE: readonly string[] = [
7171
// ── REST families (`packages/rest/src/rest-route-ledger.ts`) ────────────
7272
// `metadata` is absent because it IS classified — `anonymous-deny-meta`
73-
// covers it through the guarded registrar. The other 18 families are here.
73+
// covers it through the guarded registrar — and so is `openapi`, classified
74+
// by `anonymous-deny-api-description` since #22430. The other 16 families
75+
// are here.
7476
'rest-family:rest-route-ledger.ts:analytics',
7577
'rest-family:rest-route-ledger.ts:approvals',
7678
'rest-family:rest-route-ledger.ts:batch',
@@ -80,7 +82,6 @@ export const LEDGER_POPULATION_BASELINE: readonly string[] = [
8082
'rest-family:rest-route-ledger.ts:email',
8183
'rest-family:rest-route-ledger.ts:external-datasource',
8284
'rest-family:rest-route-ledger.ts:forms',
83-
'rest-family:rest-route-ledger.ts:openapi',
8485
// ⚠️ NOT the same surface as `dispatcher-domain:route-ledger.ts:/packages`,
8586
// which IS classified: that key names the dispatcher domain whose single
8687
// handler body carries the domain-wide gate. This one names the four routes
@@ -126,6 +127,8 @@ export const LEDGER_POPULATION_BASELINE: readonly string[] = [
126127
* 2026-09-25 (#20102): `rest-family:rest-route-ledger.ts:reports` left with the
127128
* retired saved-report family — deleted, not classified. 32 at 2026-10-01
128129
* (#21061): `dispatcher-domain:route-ledger.ts:/analytics` left classified, by
129-
* the `anonymous-deny-analytics` row and its domain gate pin.
130+
* the `anonymous-deny-analytics` row and its domain gate pin. 31 at 2026-10-09
131+
* (#22430): `rest-family:rest-route-ledger.ts:openapi` left classified, by the
132+
* `anonymous-deny-api-description` row and its booted-showcase proof.
130133
*/
131-
export const LEDGER_POPULATION_BASELINE_MAX = 32;
134+
export const LEDGER_POPULATION_BASELINE_MAX = 31;

‎packages/qa/dogfood/test/authz-probe-blind-spot.census.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,10 @@ export interface ProbeTableReading {
287287
// table as a GATE_PIN on `packages/runtime/src/domains/analytics.ts`, and the
288288
// `anonymous-deny-analytics` row covers its key plus the `/analytics`
289289
// dispatcher-domain key that left the shrink-only ledger baseline.
290-
export const PROBE_TABLE: ProbeTableReading = { entries: 19, files: 14, keys: 17 };
290+
// [#22430] 19 / 14 / 17 -> 19 / 14 / 18: no probe and no file joined; the
291+
// `anonymous-deny-api-description` row covers one more key, the `openapi` REST
292+
// family, which left the shrink-only ledger baseline.
293+
export const PROBE_TABLE: ProbeTableReading = { entries: 19, files: 14, keys: 18 };
291294

292295
/**
293296
* The probe count `authz-conformance.matrix.ts`'s header states.
@@ -342,13 +345,15 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
342345
blindSpot: 0,
343346
populationRule: 'ledger rows inside REST_ROUTE_LEDGER; reachable = rows carrying a `family` (each distinct value mints a key)',
344347
controls: { "route: '": 82, "family: '": 82, RestRouteLedgerEntry: 2 },
348+
// [#22430] The `openapi` family is the second classified one, by the
349+
// `anonymous-deny-api-description` row; the note's 2 / 16 counts it.
345350
note:
346351
'The audited disposition of every route @objectstack/rest mounts, enumerated through ' +
347352
'RestServer.getRoutes() on a booted server and guarded per route by rest-route-ledger.conformance.test.ts. ' +
348353
'That guard is why this file can be a population source and a regex table cannot: a mounted route with no ' +
349354
'row here is already RED in another package, so a new family cannot be silently absent from this file, ' +
350-
'and therefore cannot be silently absent from the authz ratchet either. 18 families; 1 classified by a ' +
351-
'matrix row (metadata), 17 enumerated in the shrink-only baseline. Re-measured 83 -> 82 when the anonymous ' +
355+
'and therefore cannot be silently absent from the authz ratchet either. 18 families; 2 classified by ' +
356+
'matrix rows (metadata and openapi), 16 enumerated in the shrink-only baseline. Re-measured 83 -> 82 when the anonymous ' +
352357
'public-form lookup-picker row left with its route; it carried `family: forms`, which the surviving form rows ' +
353358
'still carry, so `reachable` moved with `population` and the families stay 18. Earlier re-measured 91 -> 83 (19 -> 18 families) ' +
354359
'when the whole saved-report `reports` family left with its eight routes, all eight carrying the family, so ' +
@@ -499,6 +504,13 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
499504
// not move: the handler never called `enforceAuth` (the public-form routes
500505
// bypass it), so that stays 56, and `registerFormEndpoints` survives, so
501506
// `private register*Endpoints(` stays 16.
507+
// [#22430] `enforceAuth` 56 -> 58, and nothing else on this row moved:
508+
// 71 / 19 / 52 and every other control re-derived unchanged. The two
509+
// API-description handlers in `registerOpenApiEndpoints` (the document and
510+
// its viewer) now open with `if (this.enforceAuth(req, res, context))
511+
// return;` — two call sites, no prose mention. Both routes were already in
512+
// the population and stay outside `registerMetadataEndpoints`, so the blind
513+
// spot is unchanged: guarding a route is not reaching it with a key.
502514
controls: {
503515
'private register*Endpoints(': 16,
504516
'this.routeManager.register(': 64,
@@ -509,7 +521,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
509521
'registerPerItemRoute(': 8,
510522
'const registerPerItemRoute =': 1,
511523
'forwarder slice: this.routeManager.register(': 1,
512-
enforceAuth: 56,
524+
enforceAuth: 58,
513525
},
514526
note:
515527
'The single non-tripwire probe names ONE registrar of 16. The other 15 can never mint a key: ' +

‎packages/qa/dogfood/test/authz-probe-blind-spot.test.ts‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,19 +43,20 @@ describe('authz probe blind-spot census (#13260)', () => {
4343
expect(derived.table).toEqual(PROBE_TABLE);
4444
});
4545

46-
it('every classified key is accounted for — 17 `covers` keys, no more', () => {
47-
// The matrix's `covers` keys number 17 today: the 9 probe-minted keys this
46+
it('every classified key is accounted for — 18 `covers` keys, no more', () => {
47+
// The matrix's `covers` keys number 18 today: the 9 probe-minted keys this
4848
// census was first measured against, plus the 6 ledger family/domain keys
4949
// classified when the population moved (2026-08-31), plus the `/analytics`
50-
// domain's gate-pin key and its dispatcher-domain key (#21061). If a probe
50+
// domain's gate-pin key and its dispatcher-domain key (#21061), plus the
51+
// `openapi` REST family key (#22430). If a probe
5152
// starts minting a key no row covers, the ratchet itself goes red as
5253
// UNCLASSIFIED — that is its job, and this pin does not duplicate it. What
5354
// this asserts is only that the census's key count is current.
5455
//
55-
// ⚠️ This is NOT the size of the population. The ledgers mint 39 keys; 7
56-
// are classified here and 32 are enumerated in the shrink-only baseline,
56+
// ⚠️ This is NOT the size of the population. The ledgers mint 39 keys; 8
57+
// are classified here and 31 are enumerated in the shrink-only baseline,
5758
// which `authz-conformance.test.ts` holds to its own four rules.
58-
expect(PROBE_TABLE.keys).toBe(17);
59+
expect(PROBE_TABLE.keys).toBe(18);
5960
});
6061

6162
it.each(PROBE_FILE_CENSUS.map((r) => [r.file, r] as const))(

‎packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@
5050
// authz-row: anonymous-deny-automation
5151
// authz-row: anonymous-deny-packages
5252
// authz-row: anonymous-deny-analytics
53+
// authz-row: anonymous-deny-api-description
54+
//
55+
// ── Why the API-description endpoints are here (#22430) ────────────────────
56+
//
57+
// `@objectstack/rest`'s `registerOpenApiEndpoints` mounts the API-description
58+
// endpoints — the document and its viewer page — and until #22430 neither
59+
// handler asked who was calling: on a stock boot both answered an
60+
// unauthenticated caller 200, exactly as they answered a signed-in one, while
61+
// the record doors beside them answered 401. Both are driven below anonymously
62+
// (401 in the REST flat envelope, nothing of either served) and with a
63+
// signed-in member as the 200 control, on this one boot.
5364
//
5465
// ── Why the analytics faces are here (#21061) ──────────────────────────────
5566
//
@@ -137,6 +148,21 @@ const ANALYTICS_FACES: readonly AnalyticsFace[] = [
137148
{ face: 'the analytics meta face', method: 'GET', path: '/analytics/meta' },
138149
];
139150

151+
// ── #22430 — the API-description endpoints (@objectstack/rest) ──────────────
152+
//
153+
// Both register on every base the server mounts; this boot mounts only the
154+
// unscoped one (the showcase does not enable project scoping), and the
155+
// environment-scoped twin — the same handler closures — is pinned per base in
156+
// `packages/rest/src/rest-api-description-anonymous-deny.test.ts`.
157+
interface ApiDescriptionEndpoint {
158+
readonly endpoint: string;
159+
readonly path: string;
160+
}
161+
const API_DESCRIPTION_ENDPOINTS: readonly ApiDescriptionEndpoint[] = [
162+
{ endpoint: 'the API-description document', path: '/openapi.json' },
163+
{ endpoint: 'the API-description viewer', path: '/docs' },
164+
];
165+
140166
// ── #11373 — the /meta WRITE doors, driven through the REAL mount ──────────
141167
//
142168
// 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)', () =>
636662
},
637663
);
638664

665+
// ── the API-description endpoints (@objectstack/rest) — #22430 ──────────
666+
//
667+
// The refusal is the handler's first act, so the anonymous body is the REST
668+
// seam's shared one, whole, and carries nothing of the document or the page.
669+
// The member control is the teeth: the same endpoint, with a session, is
670+
// served 200 on this boot, so the 401 is the floor's answer and the endpoint
671+
// is really mounted here.
672+
it.each(API_DESCRIPTION_ENDPOINTS)('an anonymous caller on $endpoint is denied (401) and served nothing', async (ep) => {
673+
const r = await anon('GET', ep.path);
674+
expect(r.status, `${ep.endpoint}: an anonymous caller must be refused by the floor`).toBe(ANONYMOUS_DENY_STATUS);
675+
expect(r.headers.get('content-type') ?? '', `${ep.endpoint}: the refusal is JSON, never the page`).toContain('application/json');
676+
const body = await r.json();
677+
expect(body, `${ep.endpoint}: the refusal is the REST seam's shared body, whole`).toEqual(ANONYMOUS_DENY_BODY);
678+
expect(body, `${ep.endpoint}: nothing of the document rides on the refusal`).not.toHaveProperty('openapi');
679+
expect(body, `${ep.endpoint}: nothing of the document rides on the refusal`).not.toHaveProperty('paths');
680+
});
681+
682+
it('a signed-in member is served the API-description document (200) — the control', async () => {
683+
const r = await stack.apiAs(memberToken, 'GET', '/openapi.json');
684+
expect(r.status, 'an authenticated member must clear the floor and be served the document').toBe(200);
685+
const body = await r.json();
686+
expect(body.openapi, 'the served body is the OpenAPI 3.1 document').toBe('3.1.0');
687+
expect(Object.keys(body.paths ?? {}).length, 'the document describes the mounted surface').toBeGreaterThan(0);
688+
});
689+
690+
it('a signed-in member is served the API-description viewer page (200) — the control', async () => {
691+
const r = await stack.apiAs(memberToken, 'GET', '/docs');
692+
expect(r.status, 'an authenticated member must clear the floor and be served the viewer').toBe(200);
693+
expect(r.headers.get('content-type') ?? '', 'the viewer is an HTML page').toContain('text/html');
694+
// The page loads its sibling document on the same base — the request that,
695+
// from a signed-in browser, carries that browser's session.
696+
expect(await r.text(), 'the viewer points at its sibling document').toContain('data-url="/api/v1/openapi.json"');
697+
});
698+
639699
// ── one code, one message — two wrappers ───────────────────────────────
640700
it('every denied surface answers the SAME code and message (the wrappers differ)', async () => {
641701
const rest = await Promise.all([
@@ -740,6 +800,13 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () =>
740800
// [#21060] The two remaining REST families the checklist item names.
741801
{ seam: 'POST /batch', owner: '@objectstack/rest enforceAuth', family: 'rest-flat', call: () => anon('POST', BATCH, BATCH_PROBE_BODY) },
742802
{ seam: 'GET /security/explain', owner: '@objectstack/rest enforceAuth', family: 'rest-flat', call: () => anon('GET', EXPLAIN) },
803+
// [#22430] The API-description endpoints, labelled by endpoint.
804+
...API_DESCRIPTION_ENDPOINTS.map((ep) => ({
805+
seam: ep.endpoint,
806+
owner: '@objectstack/rest registerOpenApiEndpoints (enforceAuth)',
807+
family: 'rest-flat' as DenyFamily,
808+
call: () => anon('GET', ep.path),
809+
})),
743810
{ seam: 'POST /actions/:object/:action/:id', owner: 'runtime domains/actions.ts', family: 'dispatcher-wrapper', call: () => anon('POST', ACTION, { params: {} }) },
744811
{ seam: 'POST /automation/:name/trigger', owner: 'runtime domains/automation.ts', family: 'dispatcher-wrapper', call: () => anon('POST', `/automation/${FLOW}/trigger`, {}) },
745812
{ seam: 'GET /automation/_status', owner: 'runtime domains/automation.ts', family: 'dispatcher-wrapper', call: () => anon('GET', '/automation/_status') },

0 commit comments

Comments
 (0)