From 9c34e1a99163e8ae7f47595fb7d706061bc55f18 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:22:14 +0000 Subject: [PATCH 1/2] refactor(rest): move registerUiEndpoints' body into ui-routes.ts (#23050 slice 10) The body of RestServer#registerUiEndpoints (GET {basePath}/ui/view/:object/:type) moves, re-indented one level and otherwise unedited, to packages/rest/src/ui-routes.ts. The class keeps the private method and its docblock as a UiRouteHost literal plus .call. TransportScopedMetaRequest moves with it (exporting it in place changes the published d.ts by one line, measured), and rest-server.ts imports it back. The registrar list gains ui-routes.ts. Readers are re-pointed in the next commit. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude --- .../rest/src/rest-server-registrar-files.ts | 2 + packages/rest/src/rest-server.ts | 122 +++--------- packages/rest/src/ui-routes.ts | 178 ++++++++++++++++++ 3 files changed, 201 insertions(+), 101 deletions(-) create mode 100644 packages/rest/src/ui-routes.ts diff --git a/packages/rest/src/rest-server-registrar-files.ts b/packages/rest/src/rest-server-registrar-files.ts index b74d9f6ae1d..b26d5af6b76 100644 --- a/packages/rest/src/rest-server-registrar-files.ts +++ b/packages/rest/src/rest-server-registrar-files.ts @@ -47,6 +47,8 @@ export const REST_SERVER_REGISTRAR_FILES = [ 'email-routes.ts', // [#23050 slice 9] `registerSearchEndpoints`' body. 'search-routes.ts', + // [#23050 slice 10] `registerUiEndpoints`' body. + 'ui-routes.ts', ] as const; /** One file on {@link REST_SERVER_REGISTRAR_FILES}. */ diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 1c7f7e45a92..b429a90b19d 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -147,8 +147,9 @@ import type { HistoryMetaItemRequest, SaveMetaItemRequest, DeleteMetaItemRequest, - GetUiViewRequest, } from '@objectstack/spec/api'; +// `GetUiViewRequest`, the UI view door's declared request, moved with that door to +// `ui-routes.ts` (#23050 slice 10). // [#15866] The same discipline for the DATA doors. Every literal these routes // assemble is now compiled against the declared request type through // `ServerScopedDataRequest` below, so a member the contract does not declare is @@ -257,46 +258,10 @@ import { sendError as sendEnvelopeError } from '@objectstack/types'; */ export type RestProtocol = DataProtocol & MetadataProtocol; -/** - * [commit 2a29caa53] Typed TRANSPORT envelope for the meta-read doors. - * - * `environmentId` is the multi-kernel routing key, and it is OUT of the - * protocol request shape **by explicit maintainer decision** (ruling recorded - * 2026-08-18, landed as commit 2a29caa53): `resolveProtocol(environmentId)` selects the target - * kernel *before* the protocol call, and the implementation's parameter types - * (`@objectstack/metadata-protocol`) never read it off the request — the spec - * schemas (`protocol.zod.ts`) record the same exclusion schema-side. The doors - * here still spread it into the outgoing payload (long-standing wire shape, - * deliberately unchanged by the ruling), so this alias declares that one - * transport-level member on top of the declared request type. The point is - * what it makes the compiler do: every OTHER key in a door's request literal - * is now checked against the spec contract — an undeclared member is a compile - * error at the call site, not a cast-and-hope. Never add protocol members - * here; a key that belongs to the request belongs in the spec schema. - * - * [commit 45862a53d] The same typing now covers the NON-door `getMetaItems` helper call - * sites in this file (the object, book, doc, view and dataset listings). Two - * spellings those sites carry deliberately SURVIVE the tightening, because - * retiring either would change behaviour rather than typing: - * - * - The OPTIONAL CALL (`getMetaItems?.(…)`) and the - * `typeof … === 'function'` guards. `getMetaItems` is a REQUIRED - * `MetadataProtocol` member, so these are not feature detection in the - * type sense — but a host may occupy the protocol slot with an object - * that does not implement the whole surface, which is the measured reason - * they exist (`metaTypeIsLive` documents the same deliberate spelling for - * `getMetaTypes`). Retiring one turns a tolerated absence into a - * `TypeError`. - * - The RESULT handling. `getMetaItems` is declared to return - * `{ type, items }`, while these sites also tolerate the bare-array shape - * older hosts and stubs return (see `metaItemsArray`), so the response - * stays runtime-shaped on purpose. - * - * The REQUEST is what is fully typeable today, and the request is what is - * typed — which is the whole point: an undeclared key in one of these - * literals is now a compile error instead of a cast-and-hope. - */ -type TransportScopedMetaRequest = R & { environmentId?: string }; +// [#23050 slice 10] `TransportScopedMetaRequest`, the meta doors' request envelope, moved to +// `ui-routes.ts` (imported below) with the UI view door's body, its first reader outside this +// file: exporting it from here changed the published `dist/*.d.ts` by one line, and a second +// copy is not an option. // [#23050 slice 1] `ServerScopedDataRequest`, the data doors' request envelope, moved to // `data-action-routes.ts` (imported below): exporting it from this file for that module @@ -343,6 +308,8 @@ import { import { registerEmailEndpoints, type EmailRouteHost } from './email-routes.js'; // [#23050 slice 9] The global search route's body moved to its own module — see its header. import { registerSearchEndpoints, type SearchRouteHost } from './search-routes.js'; +// [#23050 slice 10] The UI view route's body moved to its own module — see its header. +import { registerUiEndpoints, type TransportScopedMetaRequest, type UiRouteHost } from './ui-routes.js'; import { loadExcelJs, type Worksheet } from './xlsx-module.js'; // [#18386] `?template=true` on the export door: the import template's column // rule, request reading and workbook. See the module header. @@ -8311,66 +8278,19 @@ export class RestServer { * mismatch {@link enforceEnvironmentOwnership} exists to catch. */ private registerUiEndpoints(basePath: string): void { - const uiPath = `${basePath}/ui`; - const isScoped = basePath.includes('/environments/:environmentId'); - - // GET /ui/view/:object/:type - Resolve view for object - this.routeManager.register({ - method: 'GET', - path: `${uiPath}/view/:object/:type`, - handler: async (req: any, res: any) => { - try { - const routeEnvironmentId = isScoped ? req.params?.environmentId : undefined; - // [commit cc837dbfe] THE environment decision for this request, taken - // once through the shared entry point and then reused — so - // the identity below, the ownership comparison and the - // protocol that answers cannot be about three different - // environments. - const environmentId = await this.resolveRequestEnvironmentId(routeEnvironmentId, req); - // A BARE site, deliberately, and the census in - // `execctx-consumer-census.test.ts` is what makes that a - // decision rather than an omission: the 52 sites whose very - // next statement is the shared anonymous floor carry no - // local `.catch`, because `computeExecCtx` already converts - // every fault except an authz-store outage into `undefined` - // and re-raises that one — which this handler's own - // `try/catch` turns into the declared fault response. A - // local `.catch(rethrowAuthzStoreUnavailable)` here would be - // behaviourally identical and would make this the only site - // that is both locally caught AND behind the floor, which is - // exactly the split that census asserts does not exist. - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - if (this.enforceEnvironmentOwnership(req, res, environmentId, context)) return; - const p = await this.resolveProtocol(environmentId, req); - if (p.getUiView) { - const viewRequest: TransportScopedMetaRequest = { - object: req.params.object, - type: req.params.type, - // [commit cc837dbfe] `routeEnvironmentId`, NOT the resolved id. - // The gate above changed WHO may reach the producer; - // it deliberately did not change WHAT the producer is - // told. This key has only ever been present on the - // scoped mount, and the shipped `getUiView` declares - // `{ object, type }` alone — widening the argument on - // the unscoped mount would be an unrelated behaviour - // change riding on a security fix. - ...(routeEnvironmentId ? { environmentId: routeEnvironmentId } : {}), - }; - const view = await p.getUiView(viewRequest); - res.json(view); - } else { - res.status(501).json({ error: 'UI View resolution not supported by protocol implementation', code: 'NOT_IMPLEMENTED' }); - } - } catch (error: any) { - handleRouteError(res, error, req.params?.object); - } - }, - metadata: { - summary: 'Resolve UI View for object', - tags: ['ui'], - }, - }); + // [#23050 slice 10] The body moved to `ui-routes.ts`. This literal + // hands it every member it reads — the list IS its dependency on this + // class (see `UiRouteHost`, and that module's header for why no + // member is a getter). + const host: UiRouteHost = { + routeManager: this.routeManager, + resolveRequestEnvironmentId: (...a) => this.resolveRequestEnvironmentId(...a), + resolveExecCtx: (...a) => this.resolveExecCtx(...a), + enforceAuth: (...a) => this.enforceAuth(...a), + enforceEnvironmentOwnership: (...a) => this.enforceEnvironmentOwnership(...a), + resolveProtocol: (...a) => this.resolveProtocol(...a), + }; + registerUiEndpoints.call(host, basePath); } /** diff --git a/packages/rest/src/ui-routes.ts b/packages/rest/src/ui-routes.ts new file mode 100644 index 00000000000..2eab547e5bb --- /dev/null +++ b/packages/rest/src/ui-routes.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23050 slice 10] The UI view route of `RestServer` — + * `GET {basePath}/ui/view/:object/:type`, the door onto the protocol's + * `getUiView` — moved out of `rest-server.ts` whole. `registerRoutes` calls the + * registrar for each base it mounts, and only while `api.enableUi` is on. + * + * A MOVE, not a redesign. The function below is the body of + * `RestServer#registerUiEndpoints` as it stood, re-indented one level and + * otherwise unedited: the same route, registered at the same position, by the + * same handler, answering the same bodies, its guards running in the same + * order. It calls no class `static`, so no token of it changed. The order of + * those guards is a ruling, recorded on the method's own docblock in + * `rest-server.ts`, which the published declaration carries. + * + * ## How the moved body reaches the class + * + * The shape slice 1 set (`data-action-routes.ts`' header carries the full + * reasoning), with slice 2's refinement. The class keeps the private method — + * the published `dist/*.d.ts` lists its name, docblock and position — and the + * body is one host literal ({@link UiRouteHost}) plus `.call(host, basePath)`. + * A method is handed over as a closure that calls it on the server at call + * time, so a test double or spy set on the server is still the one the body + * reaches. + * + * Each member was checked on its own, not inherited from an earlier slice: + * + * - `routeManager` goes BY VALUE. The body reads it only for its one + * `register` call, which runs while registration runs, and the class's one + * `routeManager` reassignment wraps `registerMetadataEndpointsInner`, never + * this registrar. + * - No member goes as a getter, because the body reads no FIELD at request + * time. The environment, the caller, the ownership verdict and the protocol + * that answers are each resolved per request by a METHOD of the class, and + * every method goes as a closure. + * + * `handleRouteError` is imported from the module `rest-server.ts` imports it + * from. `GetUiViewRequest`, which only this body read, is imported here from + * `@objectstack/spec/api` instead of there. One module-level declaration moved + * here with the body: the meta doors' request envelope, below. No export was + * added to `rest-server.ts`, so the published surface does not move. + */ + +import type { GetUiViewRequest } from '@objectstack/spec/api'; +import { handleRouteError } from './error-response.js'; +import type { RestServer } from './rest-server.js'; + +// [#23050 slice 10] Moved here from `rest-server.ts`, verbatim, because this module is +// the first one outside that file to need it: exporting it from there changed the +// published `dist/*.d.ts` by one line, and a second copy is not an option. The meta +// doors and listing helpers still in `rest-server.ts` import it from here, and the +// block's "here" and "this file" still mean `rest-server.ts`, where they live. +/** + * [commit 2a29caa53] Typed TRANSPORT envelope for the meta-read doors. + * + * `environmentId` is the multi-kernel routing key, and it is OUT of the + * protocol request shape **by explicit maintainer decision** (ruling recorded + * 2026-08-18, landed as commit 2a29caa53): `resolveProtocol(environmentId)` selects the target + * kernel *before* the protocol call, and the implementation's parameter types + * (`@objectstack/metadata-protocol`) never read it off the request — the spec + * schemas (`protocol.zod.ts`) record the same exclusion schema-side. The doors + * here still spread it into the outgoing payload (long-standing wire shape, + * deliberately unchanged by the ruling), so this alias declares that one + * transport-level member on top of the declared request type. The point is + * what it makes the compiler do: every OTHER key in a door's request literal + * is now checked against the spec contract — an undeclared member is a compile + * error at the call site, not a cast-and-hope. Never add protocol members + * here; a key that belongs to the request belongs in the spec schema. + * + * [commit 45862a53d] The same typing now covers the NON-door `getMetaItems` helper call + * sites in this file (the object, book, doc, view and dataset listings). Two + * spellings those sites carry deliberately SURVIVE the tightening, because + * retiring either would change behaviour rather than typing: + * + * - The OPTIONAL CALL (`getMetaItems?.(…)`) and the + * `typeof … === 'function'` guards. `getMetaItems` is a REQUIRED + * `MetadataProtocol` member, so these are not feature detection in the + * type sense — but a host may occupy the protocol slot with an object + * that does not implement the whole surface, which is the measured reason + * they exist (`metaTypeIsLive` documents the same deliberate spelling for + * `getMetaTypes`). Retiring one turns a tolerated absence into a + * `TypeError`. + * - The RESULT handling. `getMetaItems` is declared to return + * `{ type, items }`, while these sites also tolerate the bare-array shape + * older hosts and stubs return (see `metaItemsArray`), so the response + * stays runtime-shaped on purpose. + * + * The REQUEST is what is fully typeable today, and the request is what is + * typed — which is the whole point: an undeclared key in one of these + * literals is now a compile error instead of a cast-and-hope. + */ +export type TransportScopedMetaRequest = R & { environmentId?: string }; + +/** + * The `RestServer` members {@link registerUiEndpoints} reads — every one of + * them, and no other. Each is typed AS the class member (an indexed access on + * the class), so a change to a member's signature on the class is a change + * here too, and naming a member the class does not have is a compile error. + * See the module header for why none of them is a getter. + */ +export interface UiRouteHost { + readonly routeManager: RestServer['routeManager']; + resolveRequestEnvironmentId: RestServer['resolveRequestEnvironmentId']; + resolveExecCtx: RestServer['resolveExecCtx']; + enforceAuth: RestServer['enforceAuth']; + enforceEnvironmentOwnership: RestServer['enforceEnvironmentOwnership']; + resolveProtocol: RestServer['resolveProtocol']; +} + +/** + * Register the UI view endpoint — `RestServer#registerUiEndpoints`' body, + * called by that method with the server as `this`. The method's own docblock, + * which the published declaration carries, names the route's guards and the + * ruled order they run in. + */ +export function registerUiEndpoints(this: UiRouteHost, basePath: string): void { + const uiPath = `${basePath}/ui`; + const isScoped = basePath.includes('/environments/:environmentId'); + + // GET /ui/view/:object/:type - Resolve view for object + this.routeManager.register({ + method: 'GET', + path: `${uiPath}/view/:object/:type`, + handler: async (req: any, res: any) => { + try { + const routeEnvironmentId = isScoped ? req.params?.environmentId : undefined; + // [commit cc837dbfe] THE environment decision for this request, taken + // once through the shared entry point and then reused — so + // the identity below, the ownership comparison and the + // protocol that answers cannot be about three different + // environments. + const environmentId = await this.resolveRequestEnvironmentId(routeEnvironmentId, req); + // A BARE site, deliberately, and the census in + // `execctx-consumer-census.test.ts` is what makes that a + // decision rather than an omission: the 52 sites whose very + // next statement is the shared anonymous floor carry no + // local `.catch`, because `computeExecCtx` already converts + // every fault except an authz-store outage into `undefined` + // and re-raises that one — which this handler's own + // `try/catch` turns into the declared fault response. A + // local `.catch(rethrowAuthzStoreUnavailable)` here would be + // behaviourally identical and would make this the only site + // that is both locally caught AND behind the floor, which is + // exactly the split that census asserts does not exist. + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + if (this.enforceEnvironmentOwnership(req, res, environmentId, context)) return; + const p = await this.resolveProtocol(environmentId, req); + if (p.getUiView) { + const viewRequest: TransportScopedMetaRequest = { + object: req.params.object, + type: req.params.type, + // [commit cc837dbfe] `routeEnvironmentId`, NOT the resolved id. + // The gate above changed WHO may reach the producer; + // it deliberately did not change WHAT the producer is + // told. This key has only ever been present on the + // scoped mount, and the shipped `getUiView` declares + // `{ object, type }` alone — widening the argument on + // the unscoped mount would be an unrelated behaviour + // change riding on a security fix. + ...(routeEnvironmentId ? { environmentId: routeEnvironmentId } : {}), + }; + const view = await p.getUiView(viewRequest); + res.json(view); + } else { + res.status(501).json({ error: 'UI View resolution not supported by protocol implementation', code: 'NOT_IMPLEMENTED' }); + } + } catch (error: any) { + handleRouteError(res, error, req.params?.object); + } + }, + metadata: { + summary: 'Resolve UI View for object', + tags: ['ui'], + }, + }); +} From c9b5a64406aac92bf1d2302145028f1165948b04 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:34:35 +0000 Subject: [PATCH 2/2] test(rest): re-point the registrar-list readers to ui-routes.ts (#23050 slice 10) Every reader of the registrar list re-reads its length (10 -> 11) with its sum unchanged. The two ui-view measurement tests slice registerUiEndpoints' body where it now lives (ui-routes.ts) instead of the host literal, and the ownership guard stays pinned at exactly one call site, with this slice's one host-literal forwarder pinned beside it. check-route-envelope declares ui-routes.ts at the counts that left rest-server.ts (sums 39 and 54 unchanged). Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude --- .../qa/dogfood/test/authz-conformance.test.ts | 3 +- .../test/authz-probe-blind-spot.census.ts | 29 +++++++++++----- .../rest/src/execctx-consumer-census.test.ts | 23 ++++++++++--- .../src/meta-draft-read-door-census.test.ts | 4 ++- .../rest-server-canonical-query-ast.test.ts | 9 ++++- .../src/rest-server-docblock-position.test.ts | 13 +++++-- .../rest-server-query-number-census.test.ts | 5 ++- ...ui-view-route-identity.measurement.test.ts | 22 +++++++++--- .../ui-view-route-tenancy.measurement.test.ts | 34 ++++++++++++++++--- scripts/check-route-envelope.mjs | 33 ++++++++++++++++-- 10 files changed, 146 insertions(+), 29 deletions(-) diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index 414a4738e11..77ecc3f29f5 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -1631,7 +1631,8 @@ describe('#9084 — the rest-server.ts transport tripwire keys off mechanics', ( // [#23050 slice 7] Length 7 → 8 (`discovery-routes.ts`), still 0 over it. // [#23050 slice 8] Length 8 → 9 (`email-routes.ts`), still 0 over it. // [#23050 slice 9] Length 9 → 10 (`search-routes.ts`), still 0 over it. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(10); + // [#23050 slice 10] Length 10 → 11 (`ui-routes.ts`), still 0 over it. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(11); expect(REST_SERVER_REGISTRAR_PATHS[0]).toBe(REST_SERVER); const listed = REST_SERVER_REGISTRAR_PATHS.map((file) => readFileSync(join(REPO_ROOT, file), 'utf8')); for (const probe of [MECHANICS, ROUTES]) { 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 86e134ba529..6a8c1cf9086 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -520,12 +520,12 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'terminator spelling), PLUS `registerPerItemRoute(` call sites; counted over this file AND every ' + 'module on the registrar list (`packages/rest/src/rest-server-registrar-files.ts`) — ' + '`data-action-routes.ts`, `batch-routes.ts`, `approvals-routes.ts`, `sharing-routes.ts`, ' + - '`analytics-routes.ts`, `security-routes.ts`, `discovery-routes.ts`, `email-routes.ts` and ' + - '`search-routes.ts`, the modules the bodies of `registerDataActionEndpoints`, `registerBatchEndpoints`, ' + - '`registerApprovalsEndpoints`, `registerSharingEndpoints`, `registerSharingRuleEndpoints`, ' + - '`registerAnalyticsEndpoints`, `registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, ' + - '`registerDiscoveryEndpoints`, `registerOpenApiEndpoints`, `registerEmailEndpoints` and ' + - '`registerSearchEndpoints` moved to; ' + + '`analytics-routes.ts`, `security-routes.ts`, `discovery-routes.ts`, `email-routes.ts`, ' + + '`search-routes.ts` and `ui-routes.ts`, the modules the bodies of `registerDataActionEndpoints`, ' + + '`registerBatchEndpoints`, `registerApprovalsEndpoints`, `registerSharingEndpoints`, ' + + '`registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`, `registerSecurityExplainEndpoints`, ' + + '`registerSecurityEndpoints`, `registerDiscoveryEndpoints`, `registerOpenApiEndpoints`, ' + + '`registerEmailEndpoints`, `registerSearchEndpoints` and `registerUiEndpoints` moved to; ' + 'reachable = those inside registerMetadataEndpoints', // [#15542 / #15854] ⭐ THE POPULATION RULE LEARNED A SECOND SPELLING, and // the numbers it produces did NOT move: 80 / 19 / 61, exactly as before. @@ -758,10 +758,22 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // new occurrences are this slice's seam, the same two spellings once (the // host literal's forwarder, two; the host interface's member, two). No call // site was added. + // [#23050 slice 10] 70 / 19 / 51 UNCHANGED, the same measurement again: + // `registerUiEndpoints`' body (one registration site, `GET + // {base}/ui/view/:object/:type`, outside `registerMetadataEndpoints`, so in + // the blind spot) moved to `ui-routes.ts`, which joins the list (length + // 10 -> 11) with its own control. `this.routeManager.register(` stays 63 + // over the eleven files (20 here + 9 + 5 + 7 + 8 + 1 + 6 + 4 + 1 + 1 + 1), + // `private register*Endpoints(` stays 16 (the registrar is still declared + // here), and `enforceAuth` 104 -> 108 is RE-ANCHORED, not relaxed: the one + // call site moved with its route and is counted in both readings; the four + // new occurrences are this slice's seam, the same two spellings once (the + // host literal's forwarder, two; the host interface's member, two). No call + // site was added. controls: { 'private register*Endpoints(': 16, 'this.routeManager.register(': 63, - 'registrar list length': 10, + 'registrar list length': 11, 'data-action-routes.ts: this.routeManager.register(': 9, 'batch-routes.ts: this.routeManager.register(': 5, 'approvals-routes.ts: this.routeManager.register(': 7, @@ -771,6 +783,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'discovery-routes.ts: this.routeManager.register(': 4, 'email-routes.ts: this.routeManager.register(': 1, 'search-routes.ts: this.routeManager.register(': 1, + 'ui-routes.ts: this.routeManager.register(': 1, // Both halves of the new rule carry their own control, so neither can go // silently to zero: a helper deleted and its routes inlined back would // still read population 80, and only these two controls would notice the @@ -778,7 +791,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'registerPerItemRoute(': 8, 'const registerPerItemRoute =': 1, 'forwarder slice: this.routeManager.register(': 1, - enforceAuth: 104, + enforceAuth: 108, }, note: 'The single non-tripwire probe names ONE registrar of 16. The other 15 can never mint a key: ' + diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index 5e0741783e6..7c4df47cd18 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -109,6 +109,9 @@ const SOURCE = readFileSync(resolve(HERE, 'rest-server.ts'), 'utf8'); * * [#23050 slice 9] …and `registerSearchEndpoints`' body to `search-routes.ts`, * with its one. + * + * [#23050 slice 10] …and `registerUiEndpoints`' body to `ui-routes.ts`, with + * its one. */ const SITE_FILES = REST_SERVER_REGISTRAR_FILES; const SOURCES = new Map(SITE_FILES.map((file) => [ @@ -123,8 +126,9 @@ const siteKey = (file: string, line: number) => `${file}:${line}`; * `registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`, * `registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, * `registerOpenApiEndpoints`, `registerEmailEndpoints`, - * `registerSearchEndpoints`) hands its moved body forwards each member to the - * server with `(...a) => this.(...a)`, so a moved site's stack shows + * `registerSearchEndpoints`, `registerUiEndpoints`) hands its moved body + * forwards each member to the server with `(...a) => this.(...a)`, so + * a moved site's stack shows * that forwarder above the site itself. The site is the first censused frame * that is not one. Found by their spelling, and §2 pins ONE per moved * REGISTRAR whose body reads the seam — one per moved module, plus @@ -680,9 +684,20 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { // mentions in). Eleven forwarders now: (10 - 1) + 3 - 1, the module // carrying one registrar that reads the seam, so neither map gains an // entry. - expect(SITE_FILES).toHaveLength(10); + // + // [#23050 slice 10] 62 sites UNCHANGED / 121 → 125 mentions — the same MOVE. + // `registerUiEndpoints`' body left for `ui-routes.ts` with its one BARE + // site (guarded on its next line, as before): + // 22 + 9 + 5 + 7 + 8 + 1 + 5 + 2 + 1 + 1 + 1 = 62 sites. The four new + // mentions are this slice's seam, the two spellings once (its host + // literal's forwarder, two; its host interface's member, two): + // `rest-server.ts` goes 59 → 60 (one site out, one forwarder's two + // mentions in). Twelve forwarders now: (11 - 1) + 3 - 1, the module + // carrying one registrar that reads the seam, so neither map gains an + // entry. + expect(SITE_FILES).toHaveLength(11); expect(SITES.length).toBe(62); - expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(121); + expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(125); const extraMovedRegistrars = Object.values(EXTRA_MOVED_REGISTRARS).reduce((n, k) => n + (k ?? 0), 0); expect(extraMovedRegistrars).toBe(3); const registrarsReadingNoExecCtx = Object.values(MOVED_REGISTRARS_READING_NO_EXECCTX).reduce((n, k) => n + (k ?? 0), 0); diff --git a/packages/rest/src/meta-draft-read-door-census.test.ts b/packages/rest/src/meta-draft-read-door-census.test.ts index 61b5ba09110..70738cf899d 100644 --- a/packages/rest/src/meta-draft-read-door-census.test.ts +++ b/packages/rest/src/meta-draft-read-door-census.test.ts @@ -254,7 +254,9 @@ describe('[#20338] census: every draft switch in rest-server.ts is ledgered', () // 8 = 6 + 0 + 0 + 0 + 0 + 2 + 0 + 0 + 0, length 8 → 9. [#23050 slice 9] // Nor does the global search body (`search-routes.ts`): // 8 = 6 + 0 + 0 + 0 + 0 + 2 + 0 + 0 + 0 + 0, length 9 → 10. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(10); + // [#23050 slice 10] Nor does the UI view body (`ui-routes.ts`): + // 8 = 6 + 0 + 0 + 0 + 0 + 2 + 0 + 0 + 0 + 0 + 0, length 10 → 11. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(11); expect(SOURCE_FILES.map((file) => file.fileName)).toEqual([...REST_SERVER_REGISTRAR_FILES]); expect(SITES).toHaveLength(8); }); diff --git a/packages/rest/src/rest-server-canonical-query-ast.test.ts b/packages/rest/src/rest-server-canonical-query-ast.test.ts index 1efbc785f83..590111763d0 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -154,6 +154,11 @@ interface CensusEntry { * body (`search-routes.ts`) builds no `query:` literal either — it hands * `searchAll` a `q` / `objects` / `limit` / `perObject` request read off * `req.query`, never a `query:` slot. + * + * [#23050 slice 10] 4 = 1 + 3 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0: the UI view + * body (`ui-routes.ts`) builds no `query:` literal either — it hands + * `getUiView` an `{ object, type }` request read off the path, never a + * `query:` slot. */ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly> = { 'rest-server.ts': 1, @@ -166,6 +171,7 @@ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly { // [#23050 slice 7] Length 7 → 8, the sum unchanged. // [#23050 slice 8] Length 8 → 9, the sum unchanged. // [#23050 slice 9] Length 9 → 10, the sum unchanged. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(10); + // [#23050 slice 10] Length 10 → 11, the sum unchanged. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(11); expect(Object.keys(REGISTRAR_QUERY_SLOT_FLOORS).sort()).toEqual([...REST_SERVER_REGISTRAR_FILES].sort()); expect(CENSUS.filter(({ file }) => REGISTRAR_SOURCES.has(file)).map(({ file }) => file)) .toEqual([...REST_SERVER_REGISTRAR_FILES]); diff --git a/packages/rest/src/rest-server-docblock-position.test.ts b/packages/rest/src/rest-server-docblock-position.test.ts index 2b8c08900e7..29c745a6de8 100644 --- a/packages/rest/src/rest-server-docblock-position.test.ts +++ b/packages/rest/src/rest-server-docblock-position.test.ts @@ -175,6 +175,13 @@ describe('rest-server.ts docblock position', () => { * the module header, the host interface's block and the function's. The global * search body held no TSDoc block of its own, so `rest-server.ts` keeps every * block it had (164). + * + * [#23050 slice 10] `ui-routes.ts` carries four, as `data-action-routes.ts` + * does: the module header, the moved alias's block (`TransportScopedMetaRequest` + * went with the UI view body, as `ServerScopedDataRequest` went with slice 1's), + * the host interface's block and the function's. The UI view body held no TSDoc + * block of its own, so `rest-server.ts` gives up only the alias's block + * (164 → 163 there, still far above its floor). */ const MOVED_FILE_FLOORS: Readonly, number>> = { 'data-action-routes.ts': 4, @@ -186,6 +193,7 @@ const MOVED_FILE_FLOORS: Readonly = REST_SERVER_REGISTRAR_FILES .filter((file): file is Exclude => file !== FILE) @@ -219,9 +227,10 @@ describe('[#23050 slice 3] the registrar list this pin reads', () => { // [#23050 slice 7] 4 + 3 + 3 + 8 + 3 + 8 + 5 = 34 over seven, length 7 → 8. // [#23050 slice 8] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 = 37 over eight, length 8 → 9. // [#23050 slice 9] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 + 3 = 40 over nine, length 9 → 10. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(10); + // [#23050 slice 10] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 + 3 + 4 = 44 over ten, length 10 → 11. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(11); expect(Object.keys(MOVED_FILE_FLOORS).sort()) .toEqual(REST_SERVER_REGISTRAR_FILES.filter((file) => file !== FILE).sort()); - expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(40); + expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(44); }); }); diff --git a/packages/rest/src/rest-server-query-number-census.test.ts b/packages/rest/src/rest-server-query-number-census.test.ts index 3ddf6f8aaac..75c1ae578db 100644 --- a/packages/rest/src/rest-server-query-number-census.test.ts +++ b/packages/rest/src/rest-server-query-number-census.test.ts @@ -330,7 +330,10 @@ describe('#20139 census — every numeric coercion in rest-server.ts is classifi // Nor does the global search body (`search-routes.ts`): it READS its two // caps through `readDeclaredQueryNumber`, never coerces one: // 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0, length 9 → 10. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(10); + // [#23050 slice 10] Nor does the UI view body (`ui-routes.ts`): it reads + // only the two path parameters, as strings: + // 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0, length 10 → 11. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(11); expect(SOURCES.map(([file]) => file)).toEqual([...REST_SERVER_REGISTRAR_FILES]); expect(sites).toHaveLength(9); }); diff --git a/packages/rest/src/ui-view-route-identity.measurement.test.ts b/packages/rest/src/ui-view-route-identity.measurement.test.ts index 12d7337a21c..5130f9332e9 100644 --- a/packages/rest/src/ui-view-route-identity.measurement.test.ts +++ b/packages/rest/src/ui-view-route-identity.measurement.test.ts @@ -88,6 +88,10 @@ const SOURCE = readFileSync(resolve(HERE, 'rest-server.ts'), 'utf8'); // reverse checks pin the list's length. const REGISTRAR_SOURCES = REST_SERVER_REGISTRAR_FILES .map((file) => (file === 'rest-server.ts' ? SOURCE : readFileSync(resolve(HERE, file), 'utf8'))).join('\n'); +// [#23050 slice 10] `registerUiEndpoints`' BODY moved to `ui-routes.ts`; the +// class keeps the private method as a host literal that forwards members and +// calls none of them in a route. The sliced reading below reads the body there. +const UI_SOURCE = readFileSync(resolve(HERE, 'ui-routes.ts'), 'utf8'); const BASE = '/api/v1'; const UI_ROUTE = `GET ${BASE}/ui/view/:object/:type`; @@ -329,11 +333,18 @@ describe('[#13214] §1 the REST seam — absent context vs entitled context', () // in this file 52 times over, and `registerUiEndpoints` sits directly // above `registerCrudEndpoints`, whose handlers all carry it — so a // file-wide grep would have read as a pass before the repair too. - const start = SOURCE.indexOf('private registerUiEndpoints('); - const end = SOURCE.indexOf('private registerCrudEndpoints('); + // + // [#23050 slice 10] The body moved to `ui-routes.ts`, so it is sliced + // there: from the moved function's signature to its own closing brace + // (the first column-0 brace after it), so neither the module header nor + // a later declaration can satisfy it. Sliced in `rest-server.ts`, the + // private method is now a host literal whose forwarders spell three of + // the terms below while calling none of them in a route. + const start = UI_SOURCE.indexOf('export function registerUiEndpoints('); + const end = UI_SOURCE.indexOf('\n}\n', start); expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start); - const body = SOURCE.slice(start, end); + const body = UI_SOURCE.slice(start, end); expect(body).toContain('this.resolveProtocol('); expect(body).toContain('p.getUiView('); @@ -355,7 +366,10 @@ describe('[#13214] §1 the REST seam — absent context vs entitled context', () // that slice's forwarder. // [#23050 slice 9] 9 → 10 (`search-routes.ts`): 58 and 73, +1 each for // that slice's forwarder. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(10); + // [#23050 slice 10] 10 → 11 (`ui-routes.ts`): 59 and 74, +1 each for + // that slice's forwarder (the two call sites moved with the body and are + // counted in both readings). + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(11); expect(REGISTRAR_SOURCES.split('this.enforceAuth(').length - 1).toBeGreaterThan(40); expect(REGISTRAR_SOURCES.split('this.resolveExecCtx(').length - 1).toBeGreaterThan(40); }); diff --git a/packages/rest/src/ui-view-route-tenancy.measurement.test.ts b/packages/rest/src/ui-view-route-tenancy.measurement.test.ts index 28bd8d5d601..2a69281312c 100644 --- a/packages/rest/src/ui-view-route-tenancy.measurement.test.ts +++ b/packages/rest/src/ui-view-route-tenancy.measurement.test.ts @@ -109,6 +109,10 @@ const SOURCE = readFileSync(resolve(HERE, 'rest-server.ts'), 'utf8'); // reverse checks pin the list's length. const REGISTRAR_SOURCES = REST_SERVER_REGISTRAR_FILES .map((file) => (file === 'rest-server.ts' ? SOURCE : readFileSync(resolve(HERE, file), 'utf8'))).join('\n'); +// [#23050 slice 10] `registerUiEndpoints`' BODY moved to `ui-routes.ts`; the +// class keeps the private method as a host literal that forwards members and +// calls none of them in a route. The sliced reading below reads the body there. +const UI_SOURCE = readFileSync(resolve(HERE, 'ui-routes.ts'), 'utf8'); const BASE = '/api/v1'; const UI_ROUTE = `GET ${BASE}/ui/view/:object/:type`; @@ -1083,11 +1087,19 @@ describe('[#13214] §4 preconditions', () => { // The source half of the mechanism, scoped to the registrar so a // neighbour's code cannot satisfy it. ⚠️ Inverted: the two `false` // assertions here were the card's source-level finding. - const start = SOURCE.indexOf('private registerUiEndpoints('); - const end = SOURCE.indexOf('private registerCrudEndpoints('); + // + // [#23050 slice 10] The body moved to `ui-routes.ts`, so it is sliced + // there: from the moved function's signature to its own closing brace + // (the first column-0 brace after it), so neither the module header nor + // a later declaration can satisfy it. Sliced in `rest-server.ts`, the + // private method is now a host literal whose forwarders spell the four + // terms the ORDER assertions compare, in that same order, while calling + // none of them in a route — a slice there would pass those vacuously. + const start = UI_SOURCE.indexOf('export function registerUiEndpoints('); + const end = UI_SOURCE.indexOf('\n}\n', start); expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start); - const body = SOURCE.slice(start, end); + const body = UI_SOURCE.slice(start, end); expect(body).toContain("const isScoped = basePath.includes('/environments/:environmentId')"); // The environment is decided ONCE, through the shared entry point, and @@ -1124,12 +1136,24 @@ describe('[#13214] §4 preconditions', () => { // that slice's forwarder; the ownership guard still 1. // [#23050 slice 9] 9 → 10 (`search-routes.ts`): 58 and 73, +1 each for // that slice's forwarder; the ownership guard still 1. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(10); + // [#23050 slice 10] 10 → 11 (`ui-routes.ts`): 59 and 74, +1 each for + // that slice's forwarder (the two call sites moved with the body and are + // counted in both readings). + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(11); expect(REGISTRAR_SOURCES.split('this.enforceAuth(').length - 1).toBeGreaterThan(40); expect(REGISTRAR_SOURCES.split('this.resolveExecCtx(').length - 1).toBeGreaterThan(40); // ...and the ownership guard is NOT sprayed across the file — it is a // single new call site plus its definition. - expect(REGISTRAR_SOURCES.split('this.enforceEnvironmentOwnership(').length - 1).toBe(1); + // [#23050 slice 10] Over the list the spelling now reads 2: the ONE call + // site, which moved with its route to `ui-routes.ts`, plus this slice's + // seam — the host literal's forwarder in `rest-server.ts`, which hands + // the guard to the moved body and calls it in no route. Both halves are + // pinned exactly, so the call-site count is still 1 and a second + // forwarder reds as loudly as a second call site. + const ownershipSpellings = REGISTRAR_SOURCES.split('this.enforceEnvironmentOwnership(').length - 1; + const ownershipForwarders = REGISTRAR_SOURCES.split('(...a) => this.enforceEnvironmentOwnership(...a)').length - 1; + expect(ownershipForwarders).toBe(1); + expect(ownershipSpellings - ownershipForwarders).toBe(1); }); }); diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index fe3ff9bf291..87240d87e38 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -377,7 +377,14 @@ const MODULES = { // its catch sends `mapDataError`'s computed body), so this count does not // move and that module declares 0 below; the sum is unchanged // (16 + 13 + 8 + 1 + 0 + 0 + 0 + 0 + 1 + 0 = 39). - stringError: 16, + // + // 16 → 15 (#23050 slice 10): NOT progress — the same MOVE as slices 1–3 and 8. + // The UI view body's one literal string `error` body (its 501 + // `NOT_IMPLEMENTED`, `'UI View resolution not supported by protocol + // implementation'`) left this file with that body for `ui-routes.ts`, + // declared below at the count it had here; the sum is unchanged + // (15 + 13 + 8 + 1 + 0 + 0 + 0 + 0 + 1 + 0 + 1 = 39). + stringError: 15, // 77 → 75 (#7981): registerSecurityEndpoints' two `handleError` arms moved // off the `{ code, error }` sibling-code literal onto the shared // `respondError` helper, banking that progress per the ratchet's own rule. @@ -462,7 +469,12 @@ const MODULES = { // `search-routes.ts` carrying NO `{ code, error }` body, so this count does // not move and that module declares 0 below; the sum is unchanged // (14 + 15 + 8 + 9 + 4 + 1 + 0 + 0 + 3 + 0 = 54). - siblingCode: 14, + // + // 14 → 13 (#23050 slice 10): NOT progress — the same MOVE as `stringError` + // above. The UI view body's one `{ code, error }` body (that same 501 + // `NOT_IMPLEMENTED`) left with that body for `ui-routes.ts`, declared below; + // the sum is unchanged (13 + 15 + 8 + 9 + 4 + 1 + 0 + 0 + 3 + 0 + 1 = 54). + siblingCode: 13, }, // [#8850] The ADR-0112 error/fault-classification prologue, extracted from @@ -661,6 +673,23 @@ const MODULES = { stringError: 0, siblingCode: 0, }, + + // [#23050 slice 10] `RestServer#registerUiEndpoints`' body — the UI view + // door, `GET /ui/view/:object/:type` — moved out of `rest-server.ts` whole, on + // exactly slice 1's terms above: a MOVE, no wire answer changed, so it + // inherits `dialectOnly` and the two dialect facts, and no write-site count. + // Both counts were measured on the moved file and equal what left + // `rest-server.ts` in the same PR (stringError 16 → 15, siblingCode 14 → 13: + // the one 501 `{ error, code }` body), so each sum is unchanged. Both only + // ever tick down. Discovered by the `*-routes.ts` convention and declared in + // the same PR that created the module, so the gap never exists. + 'packages/rest/src/ui-routes.ts': { + dialectOnly: + 'moved out of `rest-server.ts` by #23050 slice 10 and held to exactly the guarantee it had there — the two dialect facts, never a write-site count', + ratchet: '#9559 (option 1: convert onto the shared sendOk/sendError)', + stringError: 1, + siblingCode: 1, + }, }; /** Identifiers whose `.json()` READS a request rather than writing a response. */