diff --git a/.changeset/25104-rest-docs-viewer-retired.md b/.changeset/25104-rest-docs-viewer-retired.md new file mode 100644 index 00000000000..5cb10f560c9 --- /dev/null +++ b/.changeset/25104-rest-docs-viewer-retired.md @@ -0,0 +1,20 @@ +--- +'@objectstack/rest': patch +--- + +feat(rest)!: `GET {base}/docs`, the OpenAPI document's HTML viewer page, is retired; `GET {base}/openapi.json` stays + +Clause-②: no (narrowing) +Metadata-protocol: none + + + +**BREAKING** (a published route stops being served), shipped as `patch` because it is a non-protocol change: it changes what a REST caller is served, not what a metadata author can write. + +`RestServer.registerOpenApiEndpoints` mounted two routes on every base: the OpenAPI document and an HTML viewer page for it. The viewer page is retired on both route shapes, the unscoped `{base}/docs` and the environment-scoped `{base}/environments/:environmentId/docs`. Nothing read it, and the API origin serves no HTML viewer. + +- **Not served now:** `GET {base}/docs` has no route. A request to it gets the host's ordinary not-found answer, the same one any unmounted path gets: on the standard server (`@objectstack/plugin-hono-server`) that is `404` with code `ENDPOINT_NOT_FOUND`, for a signed-in caller and an anonymous one alike. +- **Unchanged:** `GET {base}/openapi.json` on both route shapes, its `api.enableOpenApi` gate, its anonymous `401` and the document a signed-in caller receives. The document no longer lists the `/docs` path, because it describes only mounted routes. +- `api.enableOpenApi` now gates one route, the document. + +What changes for you. If you opened the page, load the document from `GET {base}/openapi.json` into an OpenAPI viewer of your choice. diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index 6a3246f4674..8de998a80cd 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -3434,10 +3434,10 @@ }, { "id": "access-security.anonymous-floor-v18-doors", - "title": "The anonymous floor answers 401 FIRST on the doors v18 added to it — /notifications (was 501), non-POST /keys (was 405), share-link management (was 501), /openapi.json and /docs, every /i18n face and the settings family — while the token faces and the public-form submit stay anonymous and the submit echoes only {id}", + "title": "The anonymous floor answers 401 FIRST on the doors v18 added to it — /notifications (was 501), non-POST /keys (was 405), share-link management (was 501), /openapi.json, every /i18n face and the settings family — while the token faces and the public-form submit stay anonymous and the submit echoes only {id}", "since": "v18", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": [ @@ -3456,7 +3456,8 @@ ] }, "steps": [ - "with NO credentials call: GET /api/v1/notifications, GET /api/v1/keys, GET /api/v1/share-links, GET /api/v1/openapi.json, GET /api/v1/docs, GET /api/v1/i18n/locales, GET /api/settings/branding", + "with NO credentials call: GET /api/v1/notifications, GET /api/v1/keys, GET /api/v1/share-links, GET /api/v1/openapi.json, GET /api/v1/i18n/locales, GET /api/settings/branding", + "with NO credentials, then as admin, call the retired viewer path GET /api/v1/docs, and a path nothing serves (GET /api/v1/this-route-does-not-exist) as the control", "with no credentials call a token face: GET /api/v1/share-links/BOGUS/resolve", "with no credentials POST /api/v1/forms/contact-us/submit with the four whitelisted fields plus a forged owner_id and a non-whitelisted status", "repeat two of the 401 calls as admin to prove the door itself serves" @@ -3474,6 +3475,12 @@ "verify": "status + error.code per door, anonymous", "evidence": "one status+code line per door" }, + { + "clause": "the retired viewer path GET /api/v1/docs is routed nowhere: to the anonymous caller and to the admin it answers 404 with the same body the never-served control path answers on that boot — never 401 (a door is back) and never 200 or text/html (the page is back)", + "oracle": "api", + "verify": "status + content-type + body of GET /api/v1/docs, anonymous and as admin, compared with the control path's", + "evidence": "one status+code line per call, the control included" + }, { "clause": "the share-link token face is NOT behind the floor (it answers its own invalid-token refusal, not 401)", "oracle": "api", @@ -3496,7 +3503,7 @@ ], "automated": { "kind": "dogfood", - "ref": "packages/qa/dogfood/test/anonymous-door-sweep.dogfood.test.ts + packages/rest/src/rest-api-description-anonymous-deny.test.ts + packages/runtime/src/domains/i18n-anonymous-deny.test.ts + packages/services/service-settings/src/settings-routes.anonymous-floor.pin.test.ts + packages/rest/src/public-form-submit-answer.test.ts" + "ref": "packages/qa/dogfood/test/anonymous-door-sweep.dogfood.test.ts + packages/rest/src/rest-api-description-anonymous-deny.test.ts + packages/rest/src/rest-docs-viewer-retired.pin.test.ts + packages/runtime/src/domains/i18n-anonymous-deny.test.ts + packages/services/service-settings/src/settings-routes.anonymous-floor.pin.test.ts + packages/rest/src/public-form-submit-answer.test.ts" }, "source": [ "packages/core/src/security/anonymous-deny.ts#shouldDenyAnonymous", @@ -3516,6 +3523,12 @@ "date": "2026-10-19", "change": "fixtures.knownGaps[1] marked CLOSED — a checklist-accuracy finding of the 18.0 QA sweep (QA run #24559, access-security.anonymous-floor-v18-doors; wave anchor #24579 §3): the gap said a non-POST /keys never reaches the floor on a stock boot and answers 405; since #23909 the dispatcher registers /keys for every verb (packages/runtime/src/dispatcher-plugin.ts) and the run measured anonymous GET /api/v1/keys → 401. No clause changed: acceptance[1] already demanded 401", "ref": "#24632" + }, + { + "revision": 3, + "date": "2026-10-21", + "change": "re-pointed for the retirement of GET /api/v1/docs, the OpenAPI document's HTML viewer page (no consumer; the API origin serves no HTML viewer). /docs left the title and the floored-door step, so acceptance[1] no longer demands 401 of it; a new step and acceptance[2] drive the retired path anonymously and as admin and demand the host's ordinary 404, the never-served control path's body, on that boot. automated.ref gains packages/rest/src/rest-docs-viewer-retired.pin.test.ts, the registrar-level not-served pin on both route shapes. /openapi.json stays on the floor, unchanged", + "ref": "#25104" } ] }, diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 008d36e1462..b5a52edd331 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -24,7 +24,7 @@ // hand-curated regex table reaching 1 of 17 REST registrars and 4 of 17 // dispatcher domain files. // -// The population comes from `packages/rest/src/rest-route-ledger.ts` (81 rows +// The population comes from `packages/rest/src/rest-route-ledger.ts` (80 rows // / 18 families) and `packages/runtime/src/route-ledger.ts` (105 rows / 23 // domains) because those two are enumerated from a RUNNING server and guarded // in both directions by their own conformance tests — so a new family or @@ -56,7 +56,9 @@ // read/delete rows out of the ledger — 94 rows → 91, `sdk` 84 → 81, with // families unmoved at 19 because all three departing rows carried // `family: packages`, which survives on the publish row. (91 → 83 rows and -// 19 → 18 families later, when #20102 retired the whole `reports` family.) +// 19 → 18 families later, when #20102 retired the whole `reports` family; +// 81 → 80 rows, families unmoved, when #25104 retired the `/docs` viewer +// page, whose `openapi` family survives on the document row.) // Two stale figures in // one docblock with two entirely different causes is the failure mode the pin // above ends. Widening a regex instead was refused: it rots on the next @@ -294,14 +296,16 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ 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/discovery-routes.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', + // #25104 — the document's `/docs` viewer page, the family's second route, + // was retired with its handler; the row now covers the one route left. + { id: 'anonymous-deny-api-description', summary: 'anonymous-deny on the API-description endpoint (the OpenAPI document)', state: 'enforced', + enforcement: 'rest/discovery-routes.ts registerOpenApiEndpoints — the document handler (on every base the server mounts) opens with enforceAuth (→ shouldDenyAnonymous) as its 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 + // The REST family at ledger granularity. The family is exactly this one + // route and its handler opens 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.' }, + note: 'Measured on a booted showcase before the change: an unauthenticated caller was served the document (and its viewer page, since retired) 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 this endpoint. The cited proof drives the document anonymously (401, the REST flat envelope, nothing of it served) and with a signed-in member (200, the document) 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. A deployment that publishes its API description to readers without an account publishes a static copy of the document instead.' }, // #22432 — the `/i18n` dispatcher domain, under the ruling recorded on #22146 // (the domain gains the domain-level anonymous refusal every other dispatcher // domain has, with an objectui companion for the Console's sign-in page). 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 9cd3717f60a..202a7eb2795 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -382,11 +382,15 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // permission-set overlay-discard row left with its route. It carried // `family: 'security'`, which the surviving security rows still carry, so // `reachable` moves with `population` and `keys` stays 18. - population: 81, - reachable: 81, + // [#25104] 81 / 81 / 0 -> 80 / 80 / 0: the OpenAPI document's `/docs` + // viewer page row (`GET /api/v1/docs`) left the ledger with its route. It + // carried `family: 'openapi'`, which the surviving document row still + // carries, so `reachable` moves with `population` and `keys` stays 18. + population: 80, + reachable: 80, blindSpot: 0, populationRule: 'ledger rows inside REST_ROUTE_LEDGER; reachable = rows carrying a `family` (each distinct value mints a key)', - controls: { "route: '": 81, "family: '": 81, RestRouteLedgerEntry: 2 }, + controls: { "route: '": 80, "family: '": 80, RestRouteLedgerEntry: 2 }, // [#22430] The `openapi` family is the second classified one, by the // `anonymous-deny-api-description` row; the note's 2 / 16 counts it. // [ADR-0138 E2] The `forms` family is the third, by the D2 class 1 row @@ -397,7 +401,9 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ '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; 3 classified by ' + - 'matrix rows (metadata, openapi and forms), 15 enumerated in the shrink-only baseline. Re-measured 82 -> 81 when the permission-set ' + + 'matrix rows (metadata, openapi and forms), 15 enumerated in the shrink-only baseline. Re-measured 81 -> 80 when the ' + + 'OpenAPI document viewer page row left with its route; it carried `family: openapi`, which the surviving document row ' + + 'still carries. Earlier re-measured 82 -> 81 when the permission-set ' + 'overlay-discard row left with its route; it carried `family: security`, which the surviving security rows still carry. ' + 'Earlier re-measured 83 -> 82 when the anonymous ' + 'public-form lookup-picker row left with its route; it carried `family: forms`, which the surviving form rows ' + @@ -529,9 +535,9 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ kinds: ['ROUTE_ENUMERATION', 'TRIPWIRE'], probes: 3, keys: 1, - population: 70, + population: 69, reachable: 19, - blindSpot: 51, + blindSpot: 50, populationRule: 'route registration sites — `this.routeManager.register(` call sites, LESS the one inside ' + '`registerPerItemRoute` (the shared forwarder, not a route; its extent is bounded by the declaration\'s own ' + @@ -931,9 +937,16 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // `private register*Endpoints(` stays 16, `const registerPerItemRoute =` // stays 1, `forwarder slice:` stays 1 and `enforceAuth` stays 112: the moved // groups spell none of the last four, and neither does the seam. + // [#25104] 70 / 19 / 51 -> 69 / 19 / 50, and `enforceAuth` 112 -> 111: the + // OpenAPI document's `/docs` viewer page was deleted with its handler, one + // direct `this.routeManager.register(` site (63 -> 62; 4 -> 3 in + // `discovery-routes.ts`) inside `registerOpenApiEndpoints`, NOT inside + // `registerMetadataEndpoints`, so it was a blind spot; its handler opened + // with `enforceAuth`, so that control drops by one. The registrar survives + // for the document, so `private register*Endpoints(` stays 16. controls: { 'private register*Endpoints(': 16, - 'this.routeManager.register(': 63, + 'this.routeManager.register(': 62, 'registrar list length': 18, 'data-action-routes.ts: this.routeManager.register(': 9, 'batch-routes.ts: this.routeManager.register(': 5, @@ -941,7 +954,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'sharing-routes.ts: this.routeManager.register(': 8, 'analytics-routes.ts: this.routeManager.register(': 1, 'security-routes.ts: this.routeManager.register(': 6, - 'discovery-routes.ts: this.routeManager.register(': 4, + 'discovery-routes.ts: this.routeManager.register(': 3, 'email-routes.ts: this.routeManager.register(': 1, 'search-routes.ts: this.routeManager.register(': 1, 'ui-routes.ts: this.routeManager.register(': 1, @@ -970,7 +983,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'registerPerItemRoute(': 8, 'const registerPerItemRoute =': 1, 'forwarder slice: this.routeManager.register(': 1, - enforceAuth: 112, + enforceAuth: 111, }, note: 'The single non-tripwire probe names ONE registrar of 16. The other 15 can never mint a key: ' + @@ -1247,7 +1260,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ /** * Entry points inside the probe table's OWN files that no mintable key can * reach, counting only the route/handler surfaces the ratchet's completeness - * claim is about: rest-server.ts (51 static / 56 runtime), http-dispatcher.ts + * claim is about: rest-server.ts (50 static / 55 runtime), http-dispatcher.ts * (13) and domains/mcp.ts (1). * * hono-plugin.ts's 6 mounts are deliberately EXCLUDED from this total and @@ -1262,8 +1275,11 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // [ADR-0131 cutover, stage 7-pre] 66 / 71 -> 65 / 70: the retired permission-set // overlay-discard route left rest-server.ts as one blind-spot call site and // one runtime mount. -export const BLIND_SPOT_TOTAL_STATIC = 65; -export const BLIND_SPOT_TOTAL_RUNTIME = 70; +// [#25104] 65 / 70 -> 64 / 69: the retired `/docs` viewer page left +// `discovery-routes.ts` (on rest-server.ts's registrar list) as one blind-spot +// call site and one runtime mount. +export const BLIND_SPOT_TOTAL_STATIC = 64; +export const BLIND_SPOT_TOTAL_RUNTIME = 69; /** * Re-measure every row above from the same sources the probes read. 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 af7c927917f..03f6739aa57 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 @@ -67,12 +67,14 @@ // ── 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. +// endpoint — the document — and until #22430 its handler did not ask who was +// calling: on a stock boot it answered an unauthenticated caller 200, exactly +// as it answered a signed-in one, while the record doors beside it answered +// 401. It is driven below anonymously (401 in the REST flat envelope, nothing +// of it served) and with a signed-in member as the 200 control, on this one +// boot. Its viewer page, `/docs`, was the second endpoint until #25104 retired +// it; the retired path is driven below too, and answers the host's ordinary +// not-found to everyone. // // ── Why the analytics faces are here (#21061) ────────────────────────────── // @@ -162,9 +164,9 @@ const ANALYTICS_FACES: readonly AnalyticsFace[] = [ // ── #22430 — the API-description endpoints (@objectstack/rest) ────────────── // -// Both register on every base the server mounts; this boot mounts only the +// It registers 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 +// environment-scoped twin — the same handler closure — is pinned per base in // `packages/rest/src/rest-api-description-anonymous-deny.test.ts`. interface ApiDescriptionEndpoint { readonly endpoint: string; @@ -172,9 +174,12 @@ interface ApiDescriptionEndpoint { } const API_DESCRIPTION_ENDPOINTS: readonly ApiDescriptionEndpoint[] = [ { endpoint: 'the API-description document', path: '/openapi.json' }, - { endpoint: 'the API-description viewer', path: '/docs' }, ]; +/** [#25104] The retired viewer page's path, and a path nothing ever served, for the not-found control. */ +const RETIRED_VIEWER_PATH = '/docs'; +const NEVER_SERVED_PATH = '/this-route-does-not-exist'; + // ── #22432 — the translation faces (dispatcher-mounted; runtime domains/i18n.ts) // // One handler body serves every face, so the floor is its first statement. The @@ -722,10 +727,10 @@ 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. + // seam's shared one, whole, and carries nothing of the document. 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); @@ -744,13 +749,22 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => 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"'); + it('[#25104] the retired viewer page is routed NOWHERE — the host\'s ordinary 404 for everyone, not a 401', async () => { + // Retired, the path must answer exactly what a path nothing ever served + // answers on this boot, to an anonymous caller and to a member alike: a + // 401 would mean a door is back (an auth floor only speaks for a door that + // exists), and a 200 would mean the page is. The registrar-level absence + // pin, on both route shapes, is `rest/src/rest-docs-viewer-retired.pin.test.ts`. + const control = await anon('GET', NEVER_SERVED_PATH); + expect(control.status, 'CONTROL: a path nothing serves is a 404 on this boot').toBe(404); + const controlBody = await control.text(); + const anonRes = await anon('GET', RETIRED_VIEWER_PATH); + expect(anonRes.status, 'the retired viewer path must be unrouted, not auth-refused').toBe(404); + expect(await anonRes.text(), 'the retired path answers the ordinary not-found body').toBe(controlBody); + const memberRes = await stack.apiAs(memberToken, 'GET', RETIRED_VIEWER_PATH); + expect(memberRes.status, 'a session must not change the answer on a route that is gone').toBe(404); + expect(memberRes.headers.get('content-type') ?? '', 'no page is served').not.toContain('text/html'); + expect(await memberRes.text(), 'the retired path answers the ordinary not-found body').toBe(controlBody); }); // ── the translation faces (dispatcher-mounted; runtime domains/i18n.ts) — #22432 @@ -888,7 +902,8 @@ 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. + // [#22430] The API-description endpoint (one since #25104 retired the + // viewer page), labelled by endpoint. ...API_DESCRIPTION_ENDPOINTS.map((ep) => ({ seam: ep.endpoint, owner: '@objectstack/rest registerOpenApiEndpoints (enforceAuth)', diff --git a/packages/rest/src/discovery-routes.ts b/packages/rest/src/discovery-routes.ts index 2b1c7eb3fb2..50a326cc0dc 100644 --- a/packages/rest/src/discovery-routes.ts +++ b/packages/rest/src/discovery-routes.ts @@ -3,18 +3,23 @@ /** * [#23050 slice 7] The API-description routes of `RestServer` — the discovery * document (`GET {basePath}` and `GET {basePath}/discovery`) and the OpenAPI - * pair (`GET {basePath}/openapi.json` and its `GET {basePath}/docs` viewer) — - * moved out of `rest-server.ts` whole. Both registrars run once per mounted - * base, so an environment-scoped deployment mounts each family twice. + * document (`GET {basePath}/openapi.json`) — moved out of `rest-server.ts` + * whole. Both registrars run once per mounted base, so an environment-scoped + * deployment mounts each family twice. + * + * [#25104] The OpenAPI document's HTML viewer page, `GET {basePath}/docs`, + * moved here with it and has since been RETIRED on both route shapes: nothing + * read it, and the API origin serves no HTML viewer. A request to that path + * meets the host's ordinary not-found answer; the machine-readable document + * stays. `rest-docs-viewer-retired.pin.test.ts` pins both halves. * * A MOVE, not a redesign. The two functions below are the bodies of * `RestServer#registerDiscoveryEndpoints` and * `RestServer#registerOpenApiEndpoints` as they stood, each re-indented one - * level — except the viewer page's HTML, a template literal whose lines are - * runtime text and sit at column 0, kept byte for byte — the same routes, - * registered in the same order, by the same handlers, answering the same - * bodies. Every path expression (`basePath`, `realBase`, `unscopedBase`) is - * as written. The imports only these bodies read moved with them; the two + * level — the same routes, registered in the same order, by the same + * handlers, answering the same bodies. Every path expression (`basePath`, + * `realBase`, `unscopedBase`) is as written. The imports only these bodies + * read moved with them; the two * registrars share ONE module because they are the one API-description family * (`/discovery` advertises the surfaces `/openapi.json` describes), and they * still share no code, exactly as before. @@ -67,8 +72,6 @@ // half is the class's `probeMcpServeable`, reached through the host. import { isMcpServerEnabled } from '@objectstack/types'; import { handleRouteError, sendThrownError } from './error-response.js'; -// [#25077] The docs viewer page escapes the value it places into its attribute. -import { escapeHtml } from './html-escape.js'; import { logError } from './log.js'; // [#5040 E6] The `api`-metadata endpoints the OpenAPI document folds in. import { enrichOpenApiWithEndpoints } from './openapi-endpoints.js'; @@ -447,26 +450,23 @@ export function registerDiscoveryEndpoints(this: DiscoveryRouteHost, basePath: s } /** - * Register the OpenAPI document and its viewer — `RestServer#registerOpenApiEndpoints`' + * Register the OpenAPI document — `RestServer#registerOpenApiEndpoints`' * body, called by that method with the server as `this`. The method's own - * docblock, which the published declaration carries, names the two routes and + * docblock, which the published declaration carries, names the route and * the request-time enrichment of the document. */ export function registerOpenApiEndpoints(this: OpenApiRouteHost, 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. + // [#22430] The API-description document refuses 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 it). The refusal is the 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. const openApiHandler = async (req: any, res: any) => { try { @@ -487,8 +487,9 @@ export function registerOpenApiEndpoints(this: OpenApiRouteHost, basePath: strin // expanded paths) don't bleed into the cached base spec. let enriched: any = { ...spec, servers: [...(spec.servers ?? [])] }; - // 1) Override servers[0] with the actual request origin so - // "Try it" works straight from the docs viewer. + // 1) Override servers[0] with the actual request origin so a + // client built from this document calls the server that + // served it. const host = req.headers?.host ?? req.headers?.['host']; const proto = (req.headers?.['x-forwarded-proto'] as string) || (req.protocol as string) @@ -672,56 +673,4 @@ export function registerOpenApiEndpoints(this: OpenApiRouteHost, basePath: strin tags: ['openapi'], }, }); - - // Scalar HTML viewer — single inline page that loads the spec from - // the sibling /openapi.json endpoint. No build-time bundling, no - // server-side render cost. - this.routeManager.register({ - method: 'GET', - path: `${basePath}/docs`, - handler: 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; - - // 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`; - // [#25077] Escaped for the attribute it lands in (ruling 3C on - // #25040: every outlet that renders a value into HTML escapes it - // by context). A path without HTML-special characters renders - // byte for byte as before. `html-outlets.pin.test.ts` pins both. - const html = ` - - - - -ObjectStack API Docs - - - - - -`; - 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)', - tags: ['openapi'], - }, - }); } diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index d139fbbf642..18c737940c4 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -204,8 +204,9 @@ const EXTRA_MOVED_REGISTRARS: Readonly { - it('62 invocation sites, 77 mentions — the thread\'s two control numbers hold', () => { + it('61 invocation sites, 144 mentions — the thread\'s two control numbers hold', () => { + // [#25104] 62 → 61 sites / 145 → 144 mentions: the OpenAPI document's + // `/docs` viewer page retired with its route, taking one BARE site + // (46 → 45) from `discovery-routes.ts` with it and no prose mention. The + // caught half (16) is untouched, and so is the forwarder count: the + // OpenAPI body still reads the seam, for the document. + // // [ADR-0131 cutover, stage 7-pre] 63 → 62 sites / 78 → 77 mentions: // the permission-set overlay-discard route retired, taking one BARE // site (47 → 46) with it and no prose mention. The caught half (16) @@ -833,8 +840,8 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { // counted as the history module's second registrar in BOTH maps and the new // module in `MOVED_REGISTRARS_READING_NO_EXECCTX`. expect(SITE_FILES).toHaveLength(18); - expect(SITES.length).toBe(62); - expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(145); + expect(SITES.length).toBe(61); + expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(144); const extraMovedRegistrars = Object.values(EXTRA_MOVED_REGISTRARS).reduce((n, k) => n + (k ?? 0), 0); expect(extraMovedRegistrars).toBe(4); const registrarsReadingNoExecCtx = Object.values(MOVED_REGISTRARS_READING_NO_EXECCTX).reduce((n, k) => n + (k ?? 0), 0); @@ -843,7 +850,7 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { .toHaveLength(SITE_FILES.length - 1 + extraMovedRegistrars - registrarsReadingNoExecCtx); }); - it('the split is 16 locally caught / 46 bare — NOT 12 / 46, which does not add to 62', () => { + it('the split is 16 locally caught / 45 bare — NOT 12 / 45, which does not add to 61', () => { // 12 sites spell the catch on the invocation line; 4 more spell it on // the continuation line. A single-line grep sees 12 and the arithmetic // silently loses four sites. [#20237] 15 → 13 and 23 → 21: the list @@ -857,27 +864,27 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { const sameLine = CAUGHT.filter((s) => SOURCES.get(s.file)!.split('\n')[s.line - 1].includes('.catch(')); expect(sameLine.length).toBe(12); expect(CAUGHT.length).toBe(16); - expect(BARE.length).toBe(46); + expect(BARE.length).toBe(45); expect(CAUGHT.length + BARE.length).toBe(SITES.length); }); - it('⭐ every one of the 46 bare sites is guarded on the VERY NEXT LINE, and none of the 16 caught ones is', () => { + it('⭐ every one of the 45 bare sites is guarded on the VERY NEXT LINE, and none of the 16 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(46); + expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(45); expect(CAUGHT.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(0); }); }); // --------------------------------------------------------------------------- -// 3. The 46 bare sites, driven +// 3. The 45 bare sites, driven // --------------------------------------------------------------------------- -describe('[#13160] §3 the 46 bare sites — driven, every one of them', () => { - it('all 46 are reached by the mounted route table, so none is classified by inference', async () => { +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 () => { const reached = sitesOf(await sweep(undefined, 'FULL')); const unreached = BARE.map((s) => s.key).filter((k) => !reached.has(k)); // ⛔ A bare site that stopped being reachable must show up as a @@ -885,13 +892,16 @@ describe('[#13160] §3 the 46 bare sites — driven, every one of them', () => { expect(unreached).toEqual([]); }, 120_000); - it('an absent context is the ANONYMOUS SUBJECT at all 46: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => { + it('an absent context is the ANONYMOUS SUBJECT at all 45: 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.key)); const controlByRoute = new Map(control.map((r) => [r.route, r])); const rows = fault.filter((r) => r.sites.some((l) => bareLines.has(l))); + // [#25104] The floor stays 46 although the bare sites fell to 45: 51 rows + // reach a bare line after the `/docs` viewer page retired, so the old + // floor still holds, and a floor that holds is not lowered. expect(rows.length).toBeGreaterThanOrEqual(46); for (const row of rows) { diff --git a/packages/rest/src/html-escape.ts b/packages/rest/src/html-escape.ts deleted file mode 100644 index 6f70a3c79b1..00000000000 --- a/packages/rest/src/html-escape.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#25077] The HTML escaper this package's HTML outlets call — ruling 3C on - * #25040: every outlet that renders a value into HTML escapes it by the context - * it lands in. - * - * It covers the two contexts an outlet here may place a value into: element - * text and a QUOTED attribute value (single or double quotes). The five - * characters those contexts interpret become their entities, and every other - * character passes through untouched, so a value carrying none of the five - * renders byte for byte as it did unescaped. A browser decodes each entity back - * to its character, so what the page's script reads from the DOM is the - * original value. - * - * ⛔ Not for an unquoted attribute, a `', + '`;', + '}', +].join('\n'); + describe('the outlet list — every file here that touches HTML is classified', () => { it('the census reads code, not comments (control)', () => { expect(touchesHtml('a.ts', '// renders html\nexport const x = 1;')).toBe(false); @@ -120,16 +148,21 @@ describe('the outlet list — every file here that touches HTML is classified', expect(touchesHtml('a.ts', "export const t = 'text/html';")).toBe(true); }); - it('the census finds the known outlet (positive control)', () => { - const outlet = 'discovery-routes.ts'; - expect(Object.keys(OUTLETS)).toContain(outlet); - expect(touchesHtml(outlet, readFileSync(join(SRC, outlet), 'utf8'))).toBe(true); + it('the census finds an outlet, and an unlisted one breaks the classification (positive control, on a fixture)', () => { + // The tree holds no outlet since #25104, so the control is a fixture source + // added to the tree's own texts: the census must find it, and it must be + // exactly what the classification below would red on. + const sources = shippedSourceTexts(); + expect(sources.has(FIXTURE_OUTLET_FILE), 'the fixture names no real file').toBe(false); + sources.set(FIXTURE_OUTLET_FILE, FIXTURE_OUTLET_SOURCE); + const touching = touchingFiles(sources); + expect(touching).toContain(FIXTURE_OUTLET_FILE); + expect(touching.filter((f) => !listedFiles().includes(f))).toEqual([FIXTURE_OUTLET_FILE]); + expect(touching).not.toEqual(listedFiles()); }); it('the files that touch HTML are exactly the listed outlets and non-outlets', () => { - const touching = shippedSources().filter((rel) => touchesHtml(rel, readFileSync(join(SRC, rel), 'utf8'))); - const listed = [...Object.keys(OUTLETS), ...Object.keys(NOT_OUTLETS)].sort(); - expect(touching).toEqual(listed); + expect(touchingFiles(shippedSourceTexts())).toEqual(listedFiles()); }); it('no file is both an outlet and a non-outlet', () => { @@ -139,76 +172,9 @@ describe('the outlet list — every file here that touches HTML is classified', // ── The per-entry pin ───────────────────────────────────────────────────── -const UNSCOPED = '/api/v1'; -const SCOPED = '/api/v1/environments/:environmentId'; - -/** Mount both bases, a signed-in context, and drive the GET {base}/docs handler. */ -function makeRest() { - const httpServer = { - 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; - const protocol: any = { getMetaItems: vi.fn(async () => ({ items: [] })) }; - const rest = new RestServer( - httpServer, protocol, - { api: { version: 'v1', enableProjectScoping: true, projectResolution: 'auto' } } as any, - ); - (rest as any).resolveExecCtx = vi.fn(async () => ({ userId: 'u_docs_reader' })); - rest.registerRoutes(); - return rest; -} - -/** The viewer's `data-url` attribute value, as served for a request whose path is `reqPath`. */ -async function dataUrlFor(rest: RestServer, mountPath: string, reqPath: string, environmentId?: string): Promise { - const entry = (rest as any).routeManager.get('GET', mountPath); - expect(entry, `GET ${mountPath} must be mounted`).toBeDefined(); - let html = ''; - const res: any = { - status: () => res, - json: () => res, - send: (body: string) => { html = body; return res; }, - setHeader: () => res, - header: () => res, - }; - await entry.handler({ - method: 'GET', path: reqPath, url: reqPath, - params: environmentId ? { environmentId } : {}, query: {}, - headers: { host: 'example.test' }, - }, res); - const m = html.match(/data-url="([^\n]*)"><\/script>/); - expect(m, 'the viewer page carries a data-url attribute').not.toBeNull(); - return m![1]; -} - -function pinDocsViewer(): void { - /** - * A path segment carrying the characters a quoted HTML attribute interprets. - * Kept to the minimum the context reads — a quote, an angle bracket, an - * ampersand, an apostrophe — not an attack string. - */ - const SPECIAL_ENV = 'a"<&\'b'; - const SPECIAL_ENV_ESCAPED = 'a"<&'b'; - - it('a value carrying attribute-interpreted characters is escaped for the attribute context', async () => { - const rest = makeRest(); - const reqPath = `/api/v1/environments/${SPECIAL_ENV}/docs`; - const dataUrl = await dataUrlFor(rest, `${SCOPED}/docs`, reqPath, SPECIAL_ENV); - expect(dataUrl).toBe(`/api/v1/environments/${SPECIAL_ENV_ESCAPED}/openapi.json`); - // The literal characters do not survive into the served attribute value. - expect(dataUrl).not.toContain('"'); - expect(dataUrl).not.toContain('<'); - }); - - it('an ordinary URL is served byte-identical', async () => { - const rest = makeRest(); - const unscoped = await dataUrlFor(rest, `${UNSCOPED}/docs`, '/api/v1/docs'); - expect(unscoped).toBe('/api/v1/openapi.json'); - const scoped = await dataUrlFor(rest, `${SCOPED}/docs`, '/api/v1/environments/env_1/docs', 'env_1'); - expect(scoped).toBe('/api/v1/environments/env_1/openapi.json'); - }); -} - -// Registered last, so every helper above is initialised before the pin reads it. +// Each listed outlet registers the pin that drives its real render path. The +// list is empty today (see OUTLETS), so this registers nothing until an outlet +// joins it. for (const [file, entry] of Object.entries(OUTLETS)) { describe(`${file} — ${entry.renders}`, entry.pin); } diff --git a/packages/rest/src/openapi-builtin-paths.ts b/packages/rest/src/openapi-builtin-paths.ts index b28c0f801f6..39a04e48458 100644 --- a/packages/rest/src/openapi-builtin-paths.ts +++ b/packages/rest/src/openapi-builtin-paths.ts @@ -112,9 +112,8 @@ * 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. + * data. The API-description route (`/openapi.json`) refuses an anonymous + * caller (#22430), so the inherited requirement is simply true of it. */ /** The slice of a registered route this module reads. */ diff --git a/packages/rest/src/rest-api-config-dead-keys-refused.test.ts b/packages/rest/src/rest-api-config-dead-keys-refused.test.ts index 2818ed733d9..36aa41add93 100644 --- a/packages/rest/src/rest-api-config-dead-keys-refused.test.ts +++ b/packages/rest/src/rest-api-config-dead-keys-refused.test.ts @@ -198,10 +198,10 @@ describe('[#20295] CONTROL: without the retired keys, the server is what it was' it('`api.enableOpenApi` is the OpenAPI switch the prescription names — it really unmounts the document', () => { const on = mounted({}); const off = mounted({ enableOpenApi: false }); - // Positive control: the default mounts both routes the switch owns. + // Positive control: the default mounts the route the switch owns — one + // route since the `/docs` viewer page was retired (#25104). expect(on).toContain('GET /api/v1/openapi.json'); - expect(on).toContain('GET /api/v1/docs'); - expect(on.filter((r) => !off.includes(r)).sort()).toEqual(['GET /api/v1/docs', 'GET /api/v1/openapi.json']); + expect(on.filter((r) => !off.includes(r)).sort()).toEqual(['GET /api/v1/openapi.json']); }); it('a `documentation` block without `enabled` mounts exactly the default surface — the key never reached it', () => { 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 5a5bf953586..8b36122547d 100644 --- a/packages/rest/src/rest-api-description-anonymous-deny.test.ts +++ b/packages/rest/src/rest-api-description-anonymous-deny.test.ts @@ -1,21 +1,24 @@ // 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. + * [#22430] The API-description endpoint — the document (`GET {base}/openapi.json`) + * — refuses 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: + * `RestServer.registerOpenApiEndpoints` registers it on every base the server + * mounts: the unscoped `/api/v1` and, under project scoping, its + * `/environments/:environmentId` twin. The handler opens with the shared + * anonymous-deny floor, so every base 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. + * nothing of the document 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). + * + * [#25104] The document's viewer page (`GET {base}/docs`) was the second + * endpoint here until it was retired. The assertions about it left with it, + * `rest-docs-viewer-retired.pin.test.ts` pins that it is no longer served on + * either base, and every assertion about the document stands. * * 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 @@ -43,12 +46,8 @@ 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(), @@ -125,7 +124,7 @@ describe('[#22430] the API-description endpoints refuse an anonymous caller', () 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. + // Nothing served: no document keys, no page. expect(r.body).not.toHaveProperty('openapi'); expect(r.body).not.toHaveProperty('paths'); expect(r.html).toBeUndefined(); @@ -140,13 +139,11 @@ describe('[#22430] the API-description endpoints refuse an anonymous caller', () 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)); }); }); @@ -168,10 +165,10 @@ 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 + it(`the document on ${base} describes its own endpoint as needing credentials — now true of it`, async () => { + // The operation carries no `security` of its own, so it inherits the // document-level requirement; with the refusal in place that is the - // truth about them, where before it under-claimed (see the coverage + // truth about it, 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; @@ -183,19 +180,11 @@ describe('[#22430] a signed-in caller is served as before', () => { 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)); - 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 () => { + it('a session held by the ADR-0069 auth-policy gate is refused with that gate\'s 403', async () => { const { rest, loadSpec } = makeRest(); (rest as any).resolveExecCtx = vi.fn(async () => ({ userId: 'u_gated', @@ -210,7 +199,7 @@ describe('[#22430] the floor is the shared one, not a bare anonymous check', () expect(loadSpec).not.toHaveBeenCalled(); }); - it('a permission-store outage keeps its declared 503 on both endpoints — never a 401, never a 200', async () => { + it('a permission-store outage keeps its declared 503 — never a 401, never a 200', async () => { const { rest } = makeRest(); (rest as any).resolveExecCtx = vi.fn(async () => { throw new AuthzStoreUnavailableError('sys_user_permission_set'); diff --git a/packages/rest/src/rest-config-mount-table.pin.test.ts b/packages/rest/src/rest-config-mount-table.pin.test.ts index ad88a9eb928..9f06ec39722 100644 --- a/packages/rest/src/rest-config-mount-table.pin.test.ts +++ b/packages/rest/src/rest-config-mount-table.pin.test.ts @@ -265,7 +265,9 @@ const CASES: Array<{ path: string; removes: string[] }> = [ ], }, { path: 'api.enableDiscovery', removes: ['GET /api/v1', 'GET /api/v1/discovery'] }, - { path: 'api.enableOpenApi', removes: ['GET /api/v1/docs', 'GET /api/v1/openapi.json'] }, + // [#25104] One route since the `/docs` viewer page was retired; the + // switch still gates the document. + { path: 'api.enableOpenApi', removes: ['GET /api/v1/openapi.json'] }, { path: 'api.enableUi', removes: ['GET /api/v1/ui/view/:object/:type'] }, { path: 'api.enableSearch', removes: ['GET /api/v1/search'] }, ]; diff --git a/packages/rest/src/rest-docs-viewer-retired.pin.test.ts b/packages/rest/src/rest-docs-viewer-retired.pin.test.ts new file mode 100644 index 00000000000..cad0d72e7ac --- /dev/null +++ b/packages/rest/src/rest-docs-viewer-retired.pin.test.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25104] `GET {base}/docs`, the HTML viewer page for the OpenAPI document, is + * retired: nothing read it, and the API origin serves no HTML viewer. The + * machine-readable document, `GET {base}/openapi.json`, stays. + * + * Pinned on both route shapes the server mounts: the unscoped `/api/v1` and, + * under project scoping, its `/environments/:environmentId` twin. + * + * - NOT SERVED: no `{base}/docs` registration in the route table the server + * reports (`getRoutes()`, which the document's built-in section and the + * route ledger both read), none in the route manager's own lookup, and no + * `/docs` path handed to the host's `IHttpServer` under any verb. A request + * to the retired path therefore meets the host's ordinary not-found answer. + * - NOT DESCRIBED: the served document lists no `/docs` path. + * - THE DOCUMENT STAYS: `{base}/openapi.json` is registered on both shapes + * and serves a signed-in caller the OpenAPI 3.1 body. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server.js'; + +const UNSCOPED = '/api/v1'; +const SCOPED = '/api/v1/environments/:environmentId'; +const BASES = [UNSCOPED, SCOPED] as const; +const ENV = 'env_1'; +const VERBS = ['get', 'post', 'put', 'delete', 'patch'] as const; + +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; +} + +/** A server with both bases mounted and a signed-in caller resolved for every request. */ +function makeRest() { + const httpServer = makeHttpServer(); + const protocol: any = { + getMetaItems: vi.fn(async ({ type }: { type: string }) => ({ type, items: [] })), + }; + const rest = new RestServer( + httpServer, + protocol, + { api: { version: 'v1', enableProjectScoping: true, projectResolution: 'auto' } } as any, + ); + (rest as any).resolveExecCtx = vi.fn(async () => ({ userId: 'u_docs_reader' })); + rest.registerRoutes(); + return { rest, httpServer }; +} + +/** Every path handed to the host, under any verb. */ +function hostPaths(httpServer: any): string[] { + return VERBS.flatMap((verb) => httpServer[verb].mock.calls.map((call: unknown[]) => String(call[0]))); +} + +async function serveDocument(rest: RestServer, base: string): Promise<{ status: number; body: any }> { + const path = `${base}/openapi.json`; + const entry = (rest as any).routeManager.get('GET', path); + expect(entry, `GET ${path} must be mounted`).toBeDefined(); + const observed = { status: 200, body: undefined as any }; + const res: any = { + status: (c: number) => { observed.status = c; return res; }, + json: (b: unknown) => { observed.body = b; return res; }, + send: () => res, + setHeader: () => res, + header: () => res, + }; + const wirePath = path.replace(':environmentId', ENV); + await entry.handler({ + method: 'GET', + path: wirePath, + url: wirePath, + params: base === SCOPED ? { environmentId: ENV } : {}, + query: {}, + headers: { host: 'example.test' }, + }, res); + return observed; +} + +describe('[#25104] GET {base}/docs is not served, and GET {base}/openapi.json still is', () => { + for (const base of BASES) { + it(`${base}/docs has no registration: not in getRoutes(), not in the route manager, not on the host`, () => { + const { rest, httpServer } = makeRest(); + const retired = `${base}/docs`; + expect(rest.getRoutes().filter((r) => r.path === retired)).toEqual([]); + for (const verb of VERBS) { + expect((rest as any).routeManager.get(verb.toUpperCase(), retired), `${verb.toUpperCase()} ${retired}`).toBeUndefined(); + } + expect(hostPaths(httpServer).filter((p) => p === retired)).toEqual([]); + }); + + it(`${base}/openapi.json stays registered, on the server and on the host`, () => { + const { rest, httpServer } = makeRest(); + const kept = `${base}/openapi.json`; + expect(rest.getRoutes().filter((r) => r.path === kept).map((r) => r.method.toUpperCase())).toEqual(['GET']); + expect(hostPaths(httpServer).filter((p) => p === kept)).toEqual([kept]); + }); + + it(`the document served on ${base} is the OpenAPI 3.1 body, and it describes no /docs path`, async () => { + const { rest } = makeRest(); + const { status, body } = await serveDocument(rest, base); + expect(status).toBe(200); + expect(body?.openapi).toBe('3.1.0'); + const paths = Object.keys(body?.paths ?? {}); + const template = base.replace(':environmentId', '{environmentId}'); + // CONTROL: the document describes its own route on this base, so the + // absence below is read from a populated section. + expect(paths).toContain(`${template}/openapi.json`); + expect(paths.filter((p) => p.endsWith('/docs'))).toEqual([]); + }); + } +}); diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index fd364d05c99..19ab02f79cc 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -53,7 +53,7 @@ export type RestRouteDisposition = | 'sdk' /** Should be in the SDK and is not — an open, acknowledged gap. */ | 'gap' - /** Deliberately not SDK surface (docs pages, machine-readable spec, aliases). */ + /** Deliberately not SDK surface (machine-readable spec, aliases). */ | 'server-only' /** Public, unauthenticated browser-facing route (anonymous forms etc.). */ | 'public' @@ -177,11 +177,11 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ responseSchema: 'DiscoverySchema', note: 'duplicate mount with the dispatcher /discovery branch — REST registers first and wins. Answers BARE (res.json(discovery), no envelope), so `DiscoverySchema` names the whole body here while the dispatcher row names its envelope `data`; discovery-schema-conformance.test.ts drives THIS handler through the double assertion: the live body parses against `DiscoverySchema`, and it carries no key the protocol does not declare' }, - // ── openapi / docs ──────────────────────────────────────────────────────── + // ── openapi ─────────────────────────────────────────────────────────────── + // [#25104] The document's HTML viewer page (`GET /api/v1/docs`) is retired + // and left this ledger with its route. { route: 'GET /api/v1/openapi.json', family: 'openapi', source: 'route-manager', disposition: 'server-only', note: 'machine-readable OpenAPI 3.1 (503 when not bundled); docs tooling, not SDK surface' }, - { route: 'GET /api/v1/docs', family: 'openapi', source: 'route-manager', disposition: 'server-only', - note: 'interactive Scalar HTML page' }, // ── metadata ────────────────────────────────────────────────────────────── // [2026-08-31] SEEDED under the field's fill rule: `anonymous-deny-meta` is diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 406da3d57bf..e729af2dec7 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -393,7 +393,7 @@ import { // `boundedDeclaredRefusalMessage` and `declaredHttpStatus`, read only by the // `/references` door's declared-refusal re-dresser, moved with it to // `meta-item-read-routes.ts` (#23050 slice 13b). - // `sendThrownError`, the OpenAPI document's and viewer's catch-all answer, moved with + // `sendThrownError`, the OpenAPI document's catch-all answer, moved with // `registerOpenApiEndpoints`' body to `discovery-routes.ts` (#23050 slice 7). sendDeclaredFault, sendFieldVisibilityFault, @@ -4935,10 +4935,12 @@ export class RestServer { } /** - * Register OpenAPI 3.1 spec + interactive docs viewer. + * Register the OpenAPI 3.1 document. * * GET /openapi.json → enriched OpenAPI document - * GET /docs → Scalar-rendered HTML (CDN, no dep) + * + * [#25104] Its HTML viewer page (`GET /docs`) is retired: nothing + * read it, and the API origin serves no HTML viewer. * * Enrichment at request time: * - servers[0].url — derived from the request's Host header diff --git a/scripts/adr-anchors/packages__rest__src__discovery-routes.ts.json b/scripts/adr-anchors/packages__rest__src__discovery-routes.ts.json index d60e20bea73..9c676cb5b93 100644 --- a/scripts/adr-anchors/packages__rest__src__discovery-routes.ts.json +++ b/scripts/adr-anchors/packages__rest__src__discovery-routes.ts.json @@ -5,5 +5,5 @@ "ADR-0056", "ADR-0138" ], - "invariant": "The API-description REST doors, moved here whole from `rest-server.ts` (#23050 slice 7): the discovery document (`GET {base}` and `GET {base}/discovery`) and the OpenAPI pair (`GET {base}/openapi.json` and its `GET {base}/docs` viewer), each mounted once per base. ADR-0076 — `/discovery` advertises only what this server actually mounted, and mounts what it advertises (its D12): each `routes.*` entry is rewritten to the mounted path, the direct-mount and email entries are projections of the recorded mounts, `services.*.route` is a projection of the finished `routes` map, and `transactionalBatch` and `search` are the conjunction of the protocol's capability and this server's own mount flag; and this package is the SOLE owner of `/openapi.json`, whose built-in section is produced from this server's own route table rather than inherited from the static artifact. ADR-0056 — both API-description doors are default-deny: each handler's first act refuses an anonymous caller with the shared anonymous-deny 401 (its D2), and ADR-0138's closed list of anonymous doors names neither (its D2). An advertised route that is not mounted, a second owner of the OpenAPI document, or an API-description door that answers before the anonymous refusal is the drift these records forbid; the bodies' own comments carry each citation." + "invariant": "The API-description REST doors, moved here whole from `rest-server.ts` (#23050 slice 7): the discovery document (`GET {base}` and `GET {base}/discovery`) and the OpenAPI document (`GET {base}/openapi.json`), each mounted once per base; the document's HTML viewer page (`GET {base}/docs`) is retired (#25104) and answers the host's ordinary not-found. ADR-0076 — `/discovery` advertises only what this server actually mounted, and mounts what it advertises (its D12): each `routes.*` entry is rewritten to the mounted path, the direct-mount and email entries are projections of the recorded mounts, `services.*.route` is a projection of the finished `routes` map, and `transactionalBatch` and `search` are the conjunction of the protocol's capability and this server's own mount flag; and this package is the SOLE owner of `/openapi.json`, whose built-in section is produced from this server's own route table rather than inherited from the static artifact. ADR-0056 — the API-description door is default-deny: its handler's first act refuses an anonymous caller with the shared anonymous-deny 401 (its D2), and ADR-0138's closed list of anonymous doors does not name it (its D2). An advertised route that is not mounted, a second owner of the OpenAPI document, or an API-description door that answers before the anonymous refusal is the drift these records forbid; the bodies' own comments carry each citation." }