6767// ── Why the API-description endpoints are here (#22430) ────────────────────
6868//
6969// `@objectstack/rest`'s `registerOpenApiEndpoints` mounts the API-description
70- // endpoints — the document and its viewer page — and until #22430 neither
71- // handler asked who was calling: on a stock boot both answered an
72- // unauthenticated caller 200, exactly as they answered a signed-in one, while
73- // the record doors beside them answered 401. Both are driven below anonymously
74- // (401 in the REST flat envelope, nothing of either served) and with a
75- // signed-in member as the 200 control, on this one boot.
70+ // endpoint — the document — and until #22430 its handler did not ask who was
71+ // calling: on a stock boot it answered an unauthenticated caller 200, exactly
72+ // as it answered a signed-in one, while the record doors beside it answered
73+ // 401. It is driven below anonymously (401 in the REST flat envelope, nothing
74+ // of it served) and with a signed-in member as the 200 control, on this one
75+ // boot. Its viewer page, `/docs`, was the second endpoint until #25104 retired
76+ // it; the retired path is driven below too, and answers the host's ordinary
77+ // not-found to everyone.
7678//
7779// ── Why the analytics faces are here (#21061) ──────────────────────────────
7880//
@@ -162,19 +164,22 @@ const ANALYTICS_FACES: readonly AnalyticsFace[] = [
162164
163165// ── #22430 — the API-description endpoints (@objectstack/rest) ──────────────
164166//
165- // Both register on every base the server mounts; this boot mounts only the
167+ // It registers on every base the server mounts; this boot mounts only the
166168// unscoped one (the showcase does not enable project scoping), and the
167- // environment-scoped twin — the same handler closures — is pinned per base in
169+ // environment-scoped twin — the same handler closure — is pinned per base in
168170// `packages/rest/src/rest-api-description-anonymous-deny.test.ts`.
169171interface ApiDescriptionEndpoint {
170172 readonly endpoint : string ;
171173 readonly path : string ;
172174}
173175const API_DESCRIPTION_ENDPOINTS : readonly ApiDescriptionEndpoint [ ] = [
174176 { endpoint : 'the API-description document' , path : '/openapi.json' } ,
175- { endpoint : 'the API-description viewer' , path : '/docs' } ,
176177] ;
177178
179+ /** [#25104] The retired viewer page's path, and a path nothing ever served, for the not-found control. */
180+ const RETIRED_VIEWER_PATH = '/docs' ;
181+ const NEVER_SERVED_PATH = '/this-route-does-not-exist' ;
182+
178183// ── #22432 — the translation faces (dispatcher-mounted; runtime domains/i18n.ts)
179184//
180185// 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)', () =>
722727 // ── the API-description endpoints (@objectstack/rest) — #22430 ──────────
723728 //
724729 // The refusal is the handler's first act, so the anonymous body is the REST
725- // seam's shared one, whole, and carries nothing of the document or the page.
726- // The member control is the teeth: the same endpoint, with a session, is
727- // served 200 on this boot, so the 401 is the floor's answer and the endpoint
728- // is really mounted here.
730+ // seam's shared one, whole, and carries nothing of the document. The member
731+ // control is the teeth: the same endpoint, with a session, is served 200 on
732+ // this boot, so the 401 is the floor's answer and the endpoint is really
733+ // mounted here.
729734 it . each ( API_DESCRIPTION_ENDPOINTS ) ( 'an anonymous caller on $endpoint is denied (401) and served nothing' , async ( ep ) => {
730735 const r = await anon ( 'GET' , ep . path ) ;
731736 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)', () =>
744749 expect ( Object . keys ( body . paths ?? { } ) . length , 'the document describes the mounted surface' ) . toBeGreaterThan ( 0 ) ;
745750 } ) ;
746751
747- it ( 'a signed-in member is served the API-description viewer page (200) — the control' , async ( ) => {
748- const r = await stack . apiAs ( memberToken , 'GET' , '/docs' ) ;
749- expect ( r . status , 'an authenticated member must clear the floor and be served the viewer' ) . toBe ( 200 ) ;
750- expect ( r . headers . get ( 'content-type' ) ?? '' , 'the viewer is an HTML page' ) . toContain ( 'text/html' ) ;
751- // The page loads its sibling document on the same base — the request that,
752- // from a signed-in browser, carries that browser's session.
753- expect ( await r . text ( ) , 'the viewer points at its sibling document' ) . toContain ( 'data-url="/api/v1/openapi.json"' ) ;
752+ it ( '[#25104] the retired viewer page is routed NOWHERE — the host\'s ordinary 404 for everyone, not a 401' , async ( ) => {
753+ // Retired, the path must answer exactly what a path nothing ever served
754+ // answers on this boot, to an anonymous caller and to a member alike: a
755+ // 401 would mean a door is back (an auth floor only speaks for a door that
756+ // exists), and a 200 would mean the page is. The registrar-level absence
757+ // pin, on both route shapes, is `rest/src/rest-docs-viewer-retired.pin.test.ts`.
758+ const control = await anon ( 'GET' , NEVER_SERVED_PATH ) ;
759+ expect ( control . status , 'CONTROL: a path nothing serves is a 404 on this boot' ) . toBe ( 404 ) ;
760+ const controlBody = await control . text ( ) ;
761+ const anonRes = await anon ( 'GET' , RETIRED_VIEWER_PATH ) ;
762+ expect ( anonRes . status , 'the retired viewer path must be unrouted, not auth-refused' ) . toBe ( 404 ) ;
763+ expect ( await anonRes . text ( ) , 'the retired path answers the ordinary not-found body' ) . toBe ( controlBody ) ;
764+ const memberRes = await stack . apiAs ( memberToken , 'GET' , RETIRED_VIEWER_PATH ) ;
765+ expect ( memberRes . status , 'a session must not change the answer on a route that is gone' ) . toBe ( 404 ) ;
766+ expect ( memberRes . headers . get ( 'content-type' ) ?? '' , 'no page is served' ) . not . toContain ( 'text/html' ) ;
767+ expect ( await memberRes . text ( ) , 'the retired path answers the ordinary not-found body' ) . toBe ( controlBody ) ;
754768 } ) ;
755769
756770 // ── the translation faces (dispatcher-mounted; runtime domains/i18n.ts) — #22432
@@ -888,7 +902,8 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () =>
888902 // [#21060] The two remaining REST families the checklist item names.
889903 { seam : 'POST /batch' , owner : '@objectstack/rest enforceAuth' , family : 'rest-flat' , call : ( ) => anon ( 'POST' , BATCH , BATCH_PROBE_BODY ) } ,
890904 { seam : 'GET /security/explain' , owner : '@objectstack/rest enforceAuth' , family : 'rest-flat' , call : ( ) => anon ( 'GET' , EXPLAIN ) } ,
891- // [#22430] The API-description endpoints, labelled by endpoint.
905+ // [#22430] The API-description endpoint (one since #25104 retired the
906+ // viewer page), labelled by endpoint.
892907 ...API_DESCRIPTION_ENDPOINTS . map ( ( ep ) => ( {
893908 seam : ep . endpoint ,
894909 owner : '@objectstack/rest registerOpenApiEndpoints (enforceAuth)' ,
0 commit comments