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 = ` - -
- - -