Skip to content
20 changes: 20 additions & 0 deletions .changeset/25104-rest-docs-viewer-retired.md
Original file line number Diff line number Diff line change
@@ -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

<!-- adr-0087: not-required (no-migration-prescription) A runtime route retirement in RestServer.registerOpenApiEndpoints, not a metadata change. No spec key, export, option, config field, response field or stored shape is removed, renamed or re-shaped: the page was an HTML answer on one REST path, and `objectstack migrate meta` has nothing to rewrite. The machine-readable document, its `api.enableOpenApi` gate and its answer are unchanged. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this route and this diff adds none (not registered / already-registered); and no published interface or type changes (not runtime-interface-only / type-surface-only / runtime-export-retired). -->

**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.
21 changes: 17 additions & 4 deletions docs/qa/platform-checklist/areas/access-security.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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"
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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"
}
]
},
Expand Down
18 changes: 11 additions & 7 deletions packages/qa/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
40 changes: 28 additions & 12 deletions packages/qa/dogfood/test/authz-probe-blind-spot.census.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ' +
Expand Down Expand Up @@ -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 ' +
Expand Down Expand Up @@ -931,17 +937,24 @@ 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,
'approvals-routes.ts: this.routeManager.register(': 7,
'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,
Expand Down Expand Up @@ -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: ' +
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
Loading
Loading