Skip to content

Commit 940400b

Browse files
committed
feat(rest)!: retire GET {base}/docs, the OpenAPI document's HTML viewer page
The viewer page had no consumer, and the API origin serves no HTML viewer. GET {base}/openapi.json, its gate and its answer stay. The page's escaper leaves with its only caller, the outlet census keeps running over a fixture, and the route ledger row and every pin that named the page move to the retired state. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5fb3bdb commit 940400b

10 files changed

Lines changed: 143 additions & 272 deletions

‎packages/rest/src/discovery-routes.ts‎

Lines changed: 27 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,23 @@
33
/**
44
* [#23050 slice 7] The API-description routes of `RestServer` — the discovery
55
* document (`GET {basePath}` and `GET {basePath}/discovery`) and the OpenAPI
6-
* pair (`GET {basePath}/openapi.json` and its `GET {basePath}/docs` viewer) —
7-
* moved out of `rest-server.ts` whole. Both registrars run once per mounted
8-
* base, so an environment-scoped deployment mounts each family twice.
6+
* document (`GET {basePath}/openapi.json`) — moved out of `rest-server.ts`
7+
* whole. Both registrars run once per mounted base, so an environment-scoped
8+
* deployment mounts each family twice.
9+
*
10+
* [#25104] The OpenAPI document's HTML viewer page, `GET {basePath}/docs`,
11+
* moved here with it and has since been RETIRED on both route shapes: nothing
12+
* read it, and the API origin serves no HTML viewer. A request to that path
13+
* meets the host's ordinary not-found answer; the machine-readable document
14+
* stays. `rest-docs-viewer-retired.pin.test.ts` pins both halves.
915
*
1016
* A MOVE, not a redesign. The two functions below are the bodies of
1117
* `RestServer#registerDiscoveryEndpoints` and
1218
* `RestServer#registerOpenApiEndpoints` as they stood, each re-indented one
13-
* level — except the viewer page's HTML, a template literal whose lines are
14-
* runtime text and sit at column 0, kept byte for byte — the same routes,
15-
* registered in the same order, by the same handlers, answering the same
16-
* bodies. Every path expression (`basePath`, `realBase`, `unscopedBase`) is
17-
* as written. The imports only these bodies read moved with them; the two
19+
* level — the same routes, registered in the same order, by the same
20+
* handlers, answering the same bodies. Every path expression (`basePath`,
21+
* `realBase`, `unscopedBase`) is as written. The imports only these bodies
22+
* read moved with them; the two
1823
* registrars share ONE module because they are the one API-description family
1924
* (`/discovery` advertises the surfaces `/openapi.json` describes), and they
2025
* still share no code, exactly as before.
@@ -67,8 +72,6 @@
6772
// half is the class's `probeMcpServeable`, reached through the host.
6873
import { isMcpServerEnabled } from '@objectstack/types';
6974
import { handleRouteError, sendThrownError } from './error-response.js';
70-
// [#25077] The docs viewer page escapes the value it places into its attribute.
71-
import { escapeHtml } from './html-escape.js';
7275
import { logError } from './log.js';
7376
// [#5040 E6] The `api`-metadata endpoints the OpenAPI document folds in.
7477
import { enrichOpenApiWithEndpoints } from './openapi-endpoints.js';
@@ -447,26 +450,23 @@ export function registerDiscoveryEndpoints(this: DiscoveryRouteHost, basePath: s
447450
}
448451

449452
/**
450-
* Register the OpenAPI document and its viewer — `RestServer#registerOpenApiEndpoints`'
453+
* Register the OpenAPI document — `RestServer#registerOpenApiEndpoints`'
451454
* body, called by that method with the server as `this`. The method's own
452-
* docblock, which the published declaration carries, names the two routes and
455+
* docblock, which the published declaration carries, names the route and
453456
* the request-time enrichment of the document.
454457
*/
455458
export function registerOpenApiEndpoints(this: OpenApiRouteHost, basePath: string): void {
456459
const isScoped = basePath.includes('/environments/:environmentId');
457460

458-
// [#22430] Both API-description endpoints — the document and its
459-
// viewer — refuse an anonymous caller with the shared anonymous-deny
460-
// 401, the same body the `/data` and `/meta` routes answer (ADR-0056 D2
461-
// is default-deny, and no ADR-0138 D2 door class names these two). The
462-
// refusal is each handler's FIRST act: before the bundled artifact is
463-
// loaded and before any protocol is resolved, so a refused request
464-
// costs no work and its answer does not even say whether a document is
465-
// bundled here. A signed-in caller is served exactly as before, under
466-
// the same ADR-0069 auth-policy gate every other protected route
467-
// applies. The viewer page fetches the document from the browser, and
468-
// that request carries the browser's session, so a signed-in browser is
469-
// served on both endpoints.
461+
// [#22430] The API-description document refuses an anonymous caller
462+
// with the shared anonymous-deny 401, the same body the `/data` and
463+
// `/meta` routes answer (ADR-0056 D2 is default-deny, and no ADR-0138
464+
// D2 door class names it). The refusal is the handler's FIRST act:
465+
// before the bundled artifact is loaded and before any protocol is
466+
// resolved, so a refused request costs no work and its answer does not
467+
// even say whether a document is bundled here. A signed-in caller is
468+
// served exactly as before, under the same ADR-0069 auth-policy gate
469+
// every other protected route applies.
470470

471471
const openApiHandler = async (req: any, res: any) => {
472472
try {
@@ -487,8 +487,9 @@ export function registerOpenApiEndpoints(this: OpenApiRouteHost, basePath: strin
487487
// expanded paths) don't bleed into the cached base spec.
488488
let enriched: any = { ...spec, servers: [...(spec.servers ?? [])] };
489489

490-
// 1) Override servers[0] with the actual request origin so
491-
// "Try it" works straight from the docs viewer.
490+
// 1) Override servers[0] with the actual request origin so a
491+
// client built from this document calls the server that
492+
// served it.
492493
const host = req.headers?.host ?? req.headers?.['host'];
493494
const proto = (req.headers?.['x-forwarded-proto'] as string)
494495
|| (req.protocol as string)
@@ -672,56 +673,4 @@ export function registerOpenApiEndpoints(this: OpenApiRouteHost, basePath: strin
672673
tags: ['openapi'],
673674
},
674675
});
675-
676-
// Scalar HTML viewer — single inline page that loads the spec from
677-
// the sibling /openapi.json endpoint. No build-time bundling, no
678-
// server-side render cost.
679-
this.routeManager.register({
680-
method: 'GET',
681-
path: `${basePath}/docs`,
682-
handler: async (req: any, res: any) => {
683-
try {
684-
const environmentId = isScoped ? req.params?.environmentId : undefined;
685-
const context = await this.resolveExecCtx(environmentId, req);
686-
if (this.enforceAuth(req, res, context)) return;
687-
688-
// Resolve the openapi.json URL relative to the current
689-
// request so the docs page works for any host / scoped
690-
// base path (e.g. /api/v1 vs /api/v1/environments/abc).
691-
const reqPath: string = req.path || req.url || `${basePath}/docs`;
692-
// Strip the trailing /docs to get the API base.
693-
const apiBase = reqPath.replace(/\/docs\/?$/, '');
694-
const specUrl = `${apiBase}/openapi.json`;
695-
// [#25077] Escaped for the attribute it lands in (ruling 3C on
696-
// #25040: every outlet that renders a value into HTML escapes it
697-
// by context). A path without HTML-special characters renders
698-
// byte for byte as before. `html-outlets.pin.test.ts` pins both.
699-
const html = `<!doctype html>
700-
<html>
701-
<head>
702-
<meta charset="utf-8" />
703-
<meta name="viewport" content="width=device-width, initial-scale=1" />
704-
<title>ObjectStack API Docs</title>
705-
</head>
706-
<body>
707-
<script id="api-reference" data-url="${escapeHtml(specUrl)}"></script>
708-
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
709-
</body>
710-
</html>`;
711-
if (res.setHeader) res.setHeader('content-type', 'text/html; charset=utf-8');
712-
if (res.send) res.send(html);
713-
else if (res.body) res.body = html;
714-
else res.json?.(html);
715-
} catch (error: any) {
716-
// Only the identity read above can throw here (a
717-
// permission-store outage), which keeps its declared answer.
718-
logError('[REST] docs viewer error:', error);
719-
sendThrownError(res, error);
720-
}
721-
},
722-
metadata: {
723-
summary: 'Interactive API docs (Scalar viewer)',
724-
tags: ['openapi'],
725-
},
726-
});
727676
}

‎packages/rest/src/execctx-consumer-census.test.ts‎

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -204,8 +204,9 @@ const EXTRA_MOVED_REGISTRARS: Readonly<Partial<Record<(typeof SITE_FILES)[number
204204
* so whose host literals hand over no forwarder — measured by the AST walk of
205205
* the body, not assumed. `registerDiscoveryEndpoints`' body resolves no caller
206206
* (the discovery document is served to anyone), so its host literal names no
207-
* `resolveExecCtx` member; its module's other registrar, the OpenAPI pair,
208-
* reads it twice and carries the forwarder. A body that starts reading the
207+
* `resolveExecCtx` member; its module's other registrar, the OpenAPI
208+
* document's, reads it once (twice until its `/docs` viewer page was retired,
209+
* #25104) and carries the forwarder. A body that starts reading the
209210
* seam gains a forwarder, and §2's exact count reds until this entry goes.
210211
*
211212
* [#23050 slice 11] `registerFormEndpoints`' body reads no `resolveExecCtx`
@@ -488,7 +489,13 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t
488489
// ---------------------------------------------------------------------------
489490

490491
describe('[#13160] §2 the consumer surface, counted from the tree', () => {
491-
it('62 invocation sites, 77 mentions — the thread\'s two control numbers hold', () => {
492+
it('61 invocation sites, 144 mentions — the thread\'s two control numbers hold', () => {
493+
// [#25104] 62 → 61 sites / 145 → 144 mentions: the OpenAPI document's
494+
// `/docs` viewer page retired with its route, taking one BARE site
495+
// (46 → 45) from `discovery-routes.ts` with it and no prose mention. The
496+
// caught half (16) is untouched, and so is the forwarder count: the
497+
// OpenAPI body still reads the seam, for the document.
498+
//
492499
// [ADR-0131 cutover, stage 7-pre] 63 → 62 sites / 78 → 77 mentions:
493500
// the permission-set overlay-discard route retired, taking one BARE
494501
// 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', () => {
833840
// counted as the history module's second registrar in BOTH maps and the new
834841
// module in `MOVED_REGISTRARS_READING_NO_EXECCTX`.
835842
expect(SITE_FILES).toHaveLength(18);
836-
expect(SITES.length).toBe(62);
837-
expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(145);
843+
expect(SITES.length).toBe(61);
844+
expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(144);
838845
const extraMovedRegistrars = Object.values(EXTRA_MOVED_REGISTRARS).reduce((n, k) => n + (k ?? 0), 0);
839846
expect(extraMovedRegistrars).toBe(4);
840847
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', () => {
843850
.toHaveLength(SITE_FILES.length - 1 + extraMovedRegistrars - registrarsReadingNoExecCtx);
844851
});
845852

846-
it('the split is 16 locally caught / 46 bare — NOT 12 / 46, which does not add to 62', () => {
853+
it('the split is 16 locally caught / 45 bare — NOT 12 / 45, which does not add to 61', () => {
847854
// 12 sites spell the catch on the invocation line; 4 more spell it on
848855
// the continuation line. A single-line grep sees 12 and the arithmetic
849856
// silently loses four sites. [#20237] 15 → 13 and 23 → 21: the list
@@ -857,42 +864,42 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
857864
const sameLine = CAUGHT.filter((s) => SOURCES.get(s.file)!.split('\n')[s.line - 1].includes('.catch('));
858865
expect(sameLine.length).toBe(12);
859866
expect(CAUGHT.length).toBe(16);
860-
expect(BARE.length).toBe(46);
867+
expect(BARE.length).toBe(45);
861868
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
862869
});
863870

864-
it('⭐ every one of the 46 bare sites is guarded on the VERY NEXT LINE, and none of the 16 caught ones is', () => {
871+
it('⭐ every one of the 45 bare sites is guarded on the VERY NEXT LINE, and none of the 16 caught ones is', () => {
865872
// This inverts the reason the thread gave for doing the bare sites
866873
// first ("no local signal that a fault becomes an anonymous subject").
867874
// The bare sites are bare BECAUSE the shared anonymous floor is the
868875
// next statement; the locally-caught ones carry a `.catch` because
869876
// they are NOT behind that floor and each must decide for itself.
870-
expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(46);
877+
expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(45);
871878
expect(CAUGHT.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(0);
872879
});
873880
});
874881

875882
// ---------------------------------------------------------------------------
876-
// 3. The 46 bare sites, driven
883+
// 3. The 45 bare sites, driven
877884
// ---------------------------------------------------------------------------
878885

879-
describe('[#13160] §3 the 46 bare sites — driven, every one of them', () => {
880-
it('all 46 are reached by the mounted route table, so none is classified by inference', async () => {
886+
describe('[#13160] §3 the 45 bare sites — driven, every one of them', () => {
887+
it('all 45 are reached by the mounted route table, so none is classified by inference', async () => {
881888
const reached = sitesOf(await sweep(undefined, 'FULL'));
882889
const unreached = BARE.map((s) => s.key).filter((k) => !reached.has(k));
883890
// ⛔ A bare site that stopped being reachable must show up as a
884891
// shrinking census, never as a row silently inherited from a neighbour.
885892
expect(unreached).toEqual([]);
886893
}, 120_000);
887894

888-
it('an absent context is the ANONYMOUS SUBJECT at all 46: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => {
895+
it('an absent context is the ANONYMOUS SUBJECT at all 45: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => {
889896
const fault = await sweep(undefined, 'FULL');
890897
const control = await sweep(ENTITLED, 'FULL');
891898
const bareLines = new Set(BARE.map((s) => s.key));
892899
const controlByRoute = new Map(control.map((r) => [r.route, r]));
893900

894901
const rows = fault.filter((r) => r.sites.some((l) => bareLines.has(l)));
895-
expect(rows.length).toBeGreaterThanOrEqual(46);
902+
expect(rows.length).toBeGreaterThanOrEqual(45);
896903

897904
for (const row of rows) {
898905
expect(row.status, `${row.route} under an absent context`).toBe(ANONYMOUS_DENY_STATUS);

‎packages/rest/src/html-escape.ts‎

Lines changed: 0 additions & 43 deletions
This file was deleted.

0 commit comments

Comments
 (0)