You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 1b3b65e
Browse filesBrowse the repository at this point in the historyBrowse files
feat(rest)!: retire GET {base}/docs, the OpenAPI document's HTML viewer page; /openapi.json stays (#25124)
Fixes#25104
Clause-②: no (narrowing)
Metadata-protocol: none
## What and why
`GET {base}/docs`, the HTML viewer page for the OpenAPI document, 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. This is the
maintainer's choice on the card, 「C 下线页面」.
`GET {base}/openapi.json`, its `api.enableOpenApi` gate and its answer
stay. The dispatcher's security-header defaults are not touched.
## Contract
Every input whose answer changes, old → new. Measured on this branch's
build (`packages/rest/dist` carries 0 viewer markers and 1
`openapi.json` registration). Hosts were booted with REST and dispatcher
project scoping on, as `serve.ts` forwards it.
| Input | Old | New |
| --- | --- | --- |
| `GET {base}/docs`, signed-in caller, `plugin-hono-server` | 200
`text/html`, the viewer page | 404 `application/json`,
`{"success":false,"error":{"code":"ENDPOINT_NOT_FOUND","message":"Not
found"}}` |
| `GET {base}/docs`, anonymous caller, `plugin-hono-server` | 401
`UNAUTHENTICATED` | the same 404 `ENDPOINT_NOT_FOUND` |
| `GET {base}/environments/:environmentId/docs`, signed-in and
anonymous, `plugin-hono-server` | 200 page / 401 | the same 404
`ENDPOINT_NOT_FOUND` |
| both `/docs` shapes, signed-in and anonymous, node `IHttpServer` (the
private conformance adapter) | 200 page / 401 | 404
`ENDPOINT_NOT_FOUND`, the same body |
| `GET /api/v1/docs` on a booted showcase (dogfood `bootStack`),
anonymous and member | 401 / 200 page | 404, byte-identical to the body
a never-served path gets on that boot |
| `GET {base}/docs` with a session held by an auth policy, or during a
permission-store outage | that policy's 403 / 503 | no handler exists,
so the host's not-found applies. Not measured separately on a host. |
| `paths` of `GET {base}/openapi.json` (both shapes) | lists
`{base}/docs` | does not list it |
| `api.enableOpenApi: false` | unmounts 2 routes | unmounts 1 route, the
document |
| `RestServer.getRoutes()` | one `GET {base}/docs` per mounted base |
none |
**Unchanged, measured on both RestServer hosts and both shapes:** `GET
{base}/openapi.json` answers a signed-in caller 200 with the OpenAPI 3.1
document and an anonymous caller 401 `UNAUTHENTICATED`. A path that was
never served answers the same 404 body as the retired path.
`@objectstack/hono`'s `createHonoApp` mounts no RestServer route. It
answers 404 `ROUTE_NOT_FOUND` on `/docs`, on `/openapi.json` and on a
never-served path, as it did before this change.
## The pins
**New: `packages/rest/src/rest-docs-viewer-retired.pin.test.ts`.** It
asserts the following on both route shapes:
- `{base}/docs` has no registration: none in `getRoutes()`, none in the
route manager under any verb, and no path handed to the host
`IHttpServer`.
- `{base}/openapi.json` is still registered on the server and on the
host.
- The served document is the OpenAPI 3.1 body. As a control it lists its
own `openapi.json` path, and it lists no `/docs` path.
Red on the base tree `f2b6cf230b` with only the pin added (commit
`5fb3bdb3f0`): `Tests 4 failed | 2 passed (6)`. The four not-served and
not-described cases fail; the two kept-document cases pass. Green after
the retirement: 6/6.
**Updated: what each asserted before, and what it asserts now.** No
assertion that is still true was loosened.
- `rest-api-description-anonymous-deny.test.ts`
- Before: two endpoints × two bases. Anonymous 401 for the document and
the viewer, the viewer's 401 body byte-identical to `/data`'s, the
signed-in viewer serving HTML pointed at its sibling document, the
document describing both endpoints, and the gated-session 403 and outage
503 on both endpoints.
- Now: the document only, on both bases, with every document assertion
unchanged. The viewer cases left with the viewer, and the new pin holds
its absence.
- `rest-config-mount-table.pin.test.ts`: the `api.enableOpenApi` row
went from removing `GET /api/v1/docs` and `GET /api/v1/openapi.json` to
removing `GET /api/v1/openapi.json` alone. The table still has 20
switches.
- `rest-api-config-dead-keys-refused.test.ts`
- Before: the default mounts both routes the switch owns, and `false`
removes exactly both.
- Now: the default mounts the document, and `false` removes exactly it.
- `rest-route-ledger.ts` and its conformance test: the `GET
/api/v1/docs` row is removed. The conformance test is unchanged. It
still asserts that the ledger and the mounted routes match in both
directions, and it went red in the retirement ablation only because the
row named a route that was no longer mounted.
- `execctx-consumer-census.test.ts`: sites 62 → 61, mentions 145 → 144,
bare 46 → 45, caught unchanged at 16. The §3 floor `rows ≥ 46` stays at
46: 51 rows were measured after the retirement, so the old floor still
holds and is not lowered.
- `html-outlets.pin.test.ts` (from PR #25093)
- The outlet list goes from `discovery-routes.ts` to empty.
- The non-outlet list drops `html-escape.ts` (retired) and
`rest-route-ledger.ts`, whose only HTML-touching string was the viewer
row's note. It keeps `import-template.ts`.
- The census is unchanged.
- The positive control used to read `discovery-routes.ts` from disk. It
now runs the census over this package's tree plus a fixture source
shaped like the retired page, and asserts that the census finds the
fixture and that the fixture is exactly the one unclassified file.
**Beyond the measured starting map: `packages/qa/dogfood`.** Main's full
battery runs these files; PR CI does not. Without these edits they would
go red on `main` after the merge:
- `authz-probe-blind-spot.census.ts`
- Ledger row: 81 / 81 / 0 → 80 / 80 / 0.
- `rest-server.ts` row: 70 / 19 / 51 → 69 / 19 / 50. Controls:
`this.routeManager.register(` 63 → 62, `discovery-routes.ts` 4 → 3,
`enforceAuth` 112 → 111.
- Totals: 65 / 70 → 64 / 69.
- All numbers re-derived by its own `deriveProbeFileCensus()`.
- `authz-conformance.matrix.ts`: the `anonymous-deny-api-description`
row now covers the one route left, and the docblock's ledger figure goes
from 81 to 80 rows, which its pin reads.
- `showcase-anonymous-deny-surfaces.dogfood.test.ts`: the viewer row and
its 200 member control are replaced by a retired-path case. It asserts
404 for both the anonymous caller and the member, with a body equal to
that of a never-served path.
## Also in this change
- `packages/rest/src/html-escape.ts` is retired with its only caller.
`git grep escapeHtml` over `packages/rest` finds no other caller. The
repo's other escaper is private to `plugin-email`.
- Comments that described the page are updated in `discovery-routes.ts`,
`rest-server.ts`, `openapi-builtin-paths.ts` and `rest-route-ledger.ts`.
The ADR anchor
`scripts/adr-anchors/packages__rest__src__discovery-routes.ts.json` now
names the one API-description door left.
- QA checklist `access-security.anonymous-floor-v18-doors` goes to
revision 3. `/docs` leaves the floored-door step, and a new step and
clause drive the retired path to the host's ordinary 404, following the
file's revision and history convention.
- The changeset for `@objectstack/rest` is `patch`, because this is a
non-protocol change (`Metadata-protocol: none`), and keeps its BREAKING
banner and its ADR-0087 `not-required (no-migration-prescription)`
disposition.
## Not in this change
- The `packages/spec` text that names the viewer belongs to
`domain:spec`'s #25121, which is blocked by this card. That text is the
`api.enableOpenApi` describe and the `api.documentation.enabled`
tombstone prescription in `rest-server.zod.ts`. Further `packages/spec`
sites, seen by grep and left for that card: the `liveness/rest_api.json`
evidence line, `scripts/lib/openapi-self-consistency.ts` (a comment and
a refusal message) and the prose in
`migrations/entries/semantic/18.rest-api-config-dead-keys-retired.ts`.
The generated `content/docs/references/api/rest-server.mdx` follows that
card's regeneration.
- `/openapi.json`'s gate and shape, the dispatcher's security-header
defaults, and the export/import files are untouched.
## Verification (code at `1379d30df4`, a merge of `origin/main`
`edcc8ceae6`; the head `520df1bfa6` adds only the changeset-level fix
above it)
- `@objectstack/rest` local project, run at the pre-merge head
`b55b8f782d`: `Test Files 312 passed (312)`, `Tests 6185 passed | 326
skipped (6511)`. The merge brought one `packages/spec` describe-text
commit only, so it was not re-run.
- `pnpm --filter @objectstack/rest typecheck` exit 0 (`tsc --noEmit`
plus `check:test-typecheck`). `pnpm --filter @objectstack/dogfood
typecheck` exit 0. Both programs list the edited test files.
- dogfood, run once on the final head: `authz-probe-blind-spot.test.ts`
and `authz-conformance.test.ts` 108/108;
`showcase-anonymous-deny-surfaces.dogfood.test.ts` 74/74, including the
new retired-path case.
- `@objectstack/spec` was rebuilt after the merge. `check:generated`
reports all 14 artifacts up to date.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `1379d30df4` derived 86 commands. All 86
were run, plus the 10 families listed under "Declared WIDE population",
and every one exited 0. `check:dual-build-cjs-loads` first answered
PREREQUISITE NOT MET because 8 unrelated packages had no `dist`; it was
re-run after building them (all turbo cache hits) and exited 0.
Reconciliation: `✓ dispatch-gates --ran: 86 derived famil(ies) accounted
for — 86 run, 0 NOT-MEASURED (a DERIVED zero — all 86 recorded an exit
code and none of them is 3).`
- `pnpm lint`: exit 0 on `1379d30df4` (`eslint . --no-inline-config`,
the whole tree).
## Acceptance notes
- NOT MEASURED: the new dogfood retired-path case against `main`. Its
red direction follows from main serving `/docs` with 401 to an anonymous
caller and 200 to a member, which this file asserted until now.
- This narrowing owes one `CONTRACT_REVIEW_TIER` review of record before
the queue, as the claim states.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(rest)!: `GET {base}/docs`, the OpenAPI document's HTML viewer page, is retired; `GET {base}/openapi.json` stays
6
+
7
+
Clause-②: no (narrowing)
8
+
Metadata-protocol: none
9
+
10
+
<!-- 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). -->
11
+
12
+
**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.
13
+
14
+
`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.
15
+
16
+
-**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.
17
+
-**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.
18
+
-`api.enableOpenApi` now gates one route, the document.
19
+
20
+
What changes for you. If you opened the page, load the document from `GET {base}/openapi.json` into an OpenAPI viewer of your choice.
"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}",
3437
+
"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}",
3438
3438
"since": "v18",
3439
3439
"status": "active",
3440
-
"revision": 2,
3440
+
"revision": 3,
3441
3441
"priority": "P1",
3442
3442
"surface": "api",
3443
3443
"personas": [
@@ -3456,7 +3456,8 @@
3456
3456
]
3457
3457
},
3458
3458
"steps": [
3459
-
"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",
3459
+
"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",
3460
+
"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",
3460
3461
"with no credentials call a token face: GET /api/v1/share-links/BOGUS/resolve",
3461
3462
"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",
3462
3463
"repeat two of the 401 calls as admin to prove the door itself serves"
@@ -3474,6 +3475,12 @@
3474
3475
"verify": "status + error.code per door, anonymous",
3475
3476
"evidence": "one status+code line per door"
3476
3477
},
3478
+
{
3479
+
"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)",
3480
+
"oracle": "api",
3481
+
"verify": "status + content-type + body of GET /api/v1/docs, anonymous and as admin, compared with the control path's",
3482
+
"evidence": "one status+code line per call, the control included"
3483
+
},
3477
3484
{
3478
3485
"clause": "the share-link token face is NOT behind the floor (it answers its own invalid-token refusal, not 401)",
"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",
3518
3525
"ref": "#24632"
3526
+
},
3527
+
{
3528
+
"revision": 3,
3529
+
"date": "2026-10-21",
3530
+
"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",
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.'},
295
297
// #22430 — the API-description endpoints, under the ruling recorded on #22146
296
298
// (the API-description endpoints refuse an anonymous caller with 401).
297
-
{id: 'anonymous-deny-api-description',summary: 'anonymous-deny on the API-description endpoints (the document and its viewer)',state: 'enforced',
298
-
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',
299
+
// #25104 — the document's `/docs` viewer page, the family's second route,
300
+
// was retired with its handler; the row now covers the one route left.
301
+
{id: 'anonymous-deny-api-description',summary: 'anonymous-deny on the API-description endpoint (the OpenAPI document)',state: 'enforced',
302
+
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',
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.'},
308
+
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.'},
305
309
// #22432 — the `/i18n` dispatcher domain, under the ruling recorded on #22146
306
310
// (the domain gains the domain-level anonymous refusal every other dispatcher
307
311
// domain has, with an objectui companion for the Console's sign-in page).
0 commit comments