From e8386a52350541d1f1b7b9221c3f15e6f1c7b61b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:49:07 +0000 Subject: [PATCH 1/3] refactor(rest): move registerSearchEndpoints' body into search-routes.ts Slice 9 of the rest-server.ts split, by slice 1's pattern: the body of RestServer#registerSearchEndpoints (GET {basePath}/search) moves, re-indented one level and otherwise unedited, to search-routes.ts. The class keeps the private method and its docblock as a SearchRouteHost literal plus .call. routeManager goes by value; resolveProtocol, resolveExecCtx and enforceAuth go as late-bound closures. The registrar list gains search-routes.ts (9 -> 10). 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 | 78 ++-------- packages/rest/src/search-routes.ts | 139 ++++++++++++++++++ 3 files changed, 154 insertions(+), 65 deletions(-) create mode 100644 packages/rest/src/search-routes.ts diff --git a/packages/rest/src/rest-server-registrar-files.ts b/packages/rest/src/rest-server-registrar-files.ts index 2eca0e47541..b74d9f6ae1d 100644 --- a/packages/rest/src/rest-server-registrar-files.ts +++ b/packages/rest/src/rest-server-registrar-files.ts @@ -45,6 +45,8 @@ export const REST_SERVER_REGISTRAR_FILES = [ 'discovery-routes.ts', // [#23050 slice 8] `registerEmailEndpoints`' body. 'email-routes.ts', + // [#23050 slice 9] `registerSearchEndpoints`' body. + 'search-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 2ba4159180a..1c7f7e45a92 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -341,6 +341,8 @@ import { } from './discovery-routes.js'; // [#23050 slice 8] The email route's body moved to its own module — see its header. 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'; 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. @@ -8995,71 +8997,17 @@ export class RestServer { * GET {basePath}/search?q=acme&objects=lead,account&limit=20&perObject=5 */ private registerSearchEndpoints(basePath: string): void { - const isScoped = basePath.includes('/environments/:environmentId'); - this.routeManager.register({ - method: 'GET', - path: `${basePath}/search`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const p = await this.resolveProtocol(environmentId, req); - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - const searchAll = (p as any).searchAll; - if (typeof searchAll !== 'function') { - res.status(501).json({ code: 'NOT_IMPLEMENTED', message: 'Search not supported by this protocol' }); - return; - } - // [#6877] `String(['a','b'])` searched for the literal - // term `'a,b'`. `objects` is NOT listed: the next line reads - // its array arm deliberately — a cross-object search over a - // LIST of objects is the whole point of the parameter. - // [#7606] Recognition before arity, same order and same - // envelope as the export route — see `query-allowlist.ts`. - // A dropped `?objects=` is the widening case at its worst: - // the term fans out across every searchable object while the - // caller believes they scoped it to one. - if (refuseUnknownQueryParams(req, res, GLOBAL_SEARCH_PARAMS)) return; - if (refuseRepeatedQueryParams(req, res, ['q', 'query', 'limit', 'perObject'])) return; - const q = String(req.query?.q ?? req.query?.query ?? ''); - const objectsParam = req.query?.objects; - const objects = typeof objectsParam === 'string' - ? objectsParam.split(',').map((s: string) => s.trim()).filter(Boolean) - : Array.isArray(objectsParam) ? objectsParam : undefined; - // [#20062] Read, not coerced: `?limit=abc` used to reach - // `searchAll` as `NaN`, and `hits.length >= NaN` is never true, - // so the overall cap was silently gone. No request schema is - // declared for this door, so the reading is "a whole number"; - // `searchAll`'s own `[1, 100]` clamp is unchanged, and an empty - // `?limit=` stays absent as the old falsy guard had it. - const limit = readDeclaredQueryNumber(req.query, 'limit', - UNDECLARED_WHOLE_NUMBER_PARAM, { emptyIsAbsent: true }); - // [#20139] `perObject`, the same way: `?perObject=abc` reached - // `searchAll` as `NaN`, its `Math.max(1, Math.min(25, NaN))` is - // `NaN`, and the per-object cap was silently gone. Whole number; - // the `[1, 25]` clamp stays `searchAll`'s own, and an empty - // `?perObject=` stays absent as the old falsy guard had it. - const perObject = readDeclaredQueryNumber(req.query, 'perObject', - UNDECLARED_WHOLE_NUMBER_PARAM, { emptyIsAbsent: true }); - const result = await searchAll.call(p, { - q, - objects, - limit, - perObject, - ...(context ? { context } : {}), - }); - res.json(result); - } catch (error: any) { - const mapped = mapDataError(error); - logUnexpectedRouteError(error, mapped); - res.status(mapped.status).json(mapped.body); - } - }, - metadata: { - summary: 'Global cross-object search', - tags: ['search'], - }, - }); + // [#23050 slice 9] The body moved to `search-routes.ts`. This literal + // hands it every member it reads — the list IS its dependency on this + // class (see `SearchRouteHost`, and that module's header for why no + // member is a getter). + const host: SearchRouteHost = { + routeManager: this.routeManager, + resolveProtocol: (...a) => this.resolveProtocol(...a), + resolveExecCtx: (...a) => this.resolveExecCtx(...a), + enforceAuth: (...a) => this.enforceAuth(...a), + }; + registerSearchEndpoints.call(host, basePath); } /** diff --git a/packages/rest/src/search-routes.ts b/packages/rest/src/search-routes.ts new file mode 100644 index 00000000000..a5700486380 --- /dev/null +++ b/packages/rest/src/search-routes.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23050 slice 9] The global search route of `RestServer` — + * `GET {basePath}/search`, the cross-object search door onto the protocol's + * `searchAll` — moved out of `rest-server.ts` whole. `registerRoutes` calls the + * registrar for each base it mounts, and only while `api.enableSearch` is on. + * + * A MOVE, not a redesign. The function below is the body of + * `RestServer#registerSearchEndpoints` 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. It calls no class `static`, so no + * token of it changed. + * + * ## 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 SearchRouteHost}) 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 protocol the door searches is resolved per request by + * `resolveProtocol`, a method, and the caller by `resolveExecCtx` and + * `enforceAuth`, methods too; all three are closures. + * + * Module-level helpers stay where they were and are imported: + * `GLOBAL_SEARCH_PARAMS`, `UNDECLARED_WHOLE_NUMBER_PARAM` and + * `readDeclaredQueryNumber` from `rest-server.ts`, which already exported all + * three; the rest from the modules `rest-server.ts` imports them from. No + * export was added anywhere, so the published surface does not move. + */ + +import { logUnexpectedRouteError, mapDataError } from './error-response.js'; +import { refuseRepeatedQueryParams } from './query-multiplicity.js'; +import { refuseUnknownQueryParams } from './query-allowlist.js'; +import { + GLOBAL_SEARCH_PARAMS, + UNDECLARED_WHOLE_NUMBER_PARAM, + readDeclaredQueryNumber, + type RestServer, +} from './rest-server.js'; + +/** + * The `RestServer` members {@link registerSearchEndpoints} 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 SearchRouteHost { + readonly routeManager: RestServer['routeManager']; + resolveProtocol: RestServer['resolveProtocol']; + resolveExecCtx: RestServer['resolveExecCtx']; + enforceAuth: RestServer['enforceAuth']; +} + +/** + * Register the global cross-object search endpoint — + * `RestServer#registerSearchEndpoints`' body, called by that method with the + * server as `this`. The method's own docblock, which the published declaration + * carries, names the route and its query parameters. + */ +export function registerSearchEndpoints(this: SearchRouteHost, basePath: string): void { + const isScoped = basePath.includes('/environments/:environmentId'); + this.routeManager.register({ + method: 'GET', + path: `${basePath}/search`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const p = await this.resolveProtocol(environmentId, req); + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + const searchAll = (p as any).searchAll; + if (typeof searchAll !== 'function') { + res.status(501).json({ code: 'NOT_IMPLEMENTED', message: 'Search not supported by this protocol' }); + return; + } + // [#6877] `String(['a','b'])` searched for the literal + // term `'a,b'`. `objects` is NOT listed: the next line reads + // its array arm deliberately — a cross-object search over a + // LIST of objects is the whole point of the parameter. + // [#7606] Recognition before arity, same order and same + // envelope as the export route — see `query-allowlist.ts`. + // A dropped `?objects=` is the widening case at its worst: + // the term fans out across every searchable object while the + // caller believes they scoped it to one. + if (refuseUnknownQueryParams(req, res, GLOBAL_SEARCH_PARAMS)) return; + if (refuseRepeatedQueryParams(req, res, ['q', 'query', 'limit', 'perObject'])) return; + const q = String(req.query?.q ?? req.query?.query ?? ''); + const objectsParam = req.query?.objects; + const objects = typeof objectsParam === 'string' + ? objectsParam.split(',').map((s: string) => s.trim()).filter(Boolean) + : Array.isArray(objectsParam) ? objectsParam : undefined; + // [#20062] Read, not coerced: `?limit=abc` used to reach + // `searchAll` as `NaN`, and `hits.length >= NaN` is never true, + // so the overall cap was silently gone. No request schema is + // declared for this door, so the reading is "a whole number"; + // `searchAll`'s own `[1, 100]` clamp is unchanged, and an empty + // `?limit=` stays absent as the old falsy guard had it. + const limit = readDeclaredQueryNumber(req.query, 'limit', + UNDECLARED_WHOLE_NUMBER_PARAM, { emptyIsAbsent: true }); + // [#20139] `perObject`, the same way: `?perObject=abc` reached + // `searchAll` as `NaN`, its `Math.max(1, Math.min(25, NaN))` is + // `NaN`, and the per-object cap was silently gone. Whole number; + // the `[1, 25]` clamp stays `searchAll`'s own, and an empty + // `?perObject=` stays absent as the old falsy guard had it. + const perObject = readDeclaredQueryNumber(req.query, 'perObject', + UNDECLARED_WHOLE_NUMBER_PARAM, { emptyIsAbsent: true }); + const result = await searchAll.call(p, { + q, + objects, + limit, + perObject, + ...(context ? { context } : {}), + }); + res.json(result); + } catch (error: any) { + const mapped = mapDataError(error); + logUnexpectedRouteError(error, mapped); + res.status(mapped.status).json(mapped.body); + } + }, + metadata: { + summary: 'Global cross-object search', + tags: ['search'], + }, + }); +} From 6a57870feef385ced6162c04bd29f6491932be22 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:59:54 +0000 Subject: [PATCH 2/3] refactor(rest): search-routes.ts header names no host member in prose The module header's prose named the three closures, which every census that counts those spellings over the registrar list would have read as three more mentions. It now describes them without naming them. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude --- packages/rest/src/search-routes.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/rest/src/search-routes.ts b/packages/rest/src/search-routes.ts index a5700486380..7161079aedd 100644 --- a/packages/rest/src/search-routes.ts +++ b/packages/rest/src/search-routes.ts @@ -29,9 +29,9 @@ * `routeManager` reassignment wraps `registerMetadataEndpointsInner`, never * this registrar. * - No member goes as a getter, because the body reads no FIELD at request - * time. The protocol the door searches is resolved per request by - * `resolveProtocol`, a method, and the caller by `resolveExecCtx` and - * `enforceAuth`, methods too; all three are closures. + * time. The protocol the door searches and the caller it answers are each + * resolved per request by a METHOD of the class, and every method goes as a + * closure. * * Module-level helpers stay where they were and are imported: * `GLOBAL_SEARCH_PARAMS`, `UNDECLARED_WHOLE_NUMBER_PARAM` and From 0ba4e56177bc1ae42461bea0b96dd35f9a7efc55 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 13:09:57 +0000 Subject: [PATCH 3/3] test(rest): re-point the readers of registerSearchEndpoints' moved lines Every reader whose reading included a moved line now reads it in search-routes.ts, each with its sum unchanged: - the nine registrar-list readers pin length 10 (execctx census 62 sites, 117 -> 121 mentions, 11 forwarders; ui-view pair 58 / 73; docblock floors 37 -> 40; query-number 9, canonical query 4, meta-draft 8, realtime tripwires 0); - the dogfood blind-spot row: 70 / 19 / 51 unchanged, this.routeManager.register( 63 over ten files, a new search-routes.ts control (1), enforceAuth 100 -> 104 re-anchored; - check-route-envelope declares search-routes.ts at stringError 0 / siblingCode 0, and rest-server.ts keeps 16 / 14 (sums 39 and 54). 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 | 28 ++++++++++++----- .../rest/src/execctx-consumer-census.test.ts | 30 ++++++++++++++----- .../src/meta-draft-read-door-census.test.ts | 6 ++-- .../rest-server-canonical-query-ast.test.ts | 9 +++++- .../src/rest-server-docblock-position.test.ts | 11 +++++-- .../rest-server-query-number-census.test.ts | 7 +++-- ...ui-view-route-identity.measurement.test.ts | 4 ++- .../ui-view-route-tenancy.measurement.test.ts | 4 ++- scripts/check-route-envelope.mjs | 29 ++++++++++++++++++ 10 files changed, 106 insertions(+), 25 deletions(-) diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index 8f451519976..414a4738e11 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -1630,7 +1630,8 @@ describe('#9084 — the rest-server.ts transport tripwire keys off mechanics', ( // [#23050 slice 6] Length 6 → 7 (`security-routes.ts`), still 0 over it. // [#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. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(9); + // [#23050 slice 9] Length 9 → 10 (`search-routes.ts`), still 0 over it. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(10); 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 6c81d64cde1..86e134ba529 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -520,11 +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` and `email-routes.ts`, the modules ' + - 'the bodies of `registerDataActionEndpoints`, `registerBatchEndpoints`, `registerApprovalsEndpoints`, ' + - '`registerSharingEndpoints`, `registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`, ' + - '`registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, `registerDiscoveryEndpoints`, ' + - '`registerOpenApiEndpoints` and `registerEmailEndpoints` moved to; ' + + '`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; ' + '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. @@ -745,10 +746,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 9] 70 / 19 / 51 UNCHANGED, the same measurement again: + // `registerSearchEndpoints`' body (one registration site, `GET + // {base}/search`, outside `registerMetadataEndpoints`, so in the blind spot) + // moved to `search-routes.ts`, which joins the list (length 9 -> 10) with + // its own control. `this.routeManager.register(` stays 63 over the ten + // files (21 here + 9 + 5 + 7 + 8 + 1 + 6 + 4 + 1 + 1), + // `private register*Endpoints(` stays 16 (the registrar is still declared + // here), and `enforceAuth` 100 -> 104 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': 9, + 'registrar list length': 10, 'data-action-routes.ts: this.routeManager.register(': 9, 'batch-routes.ts: this.routeManager.register(': 5, 'approvals-routes.ts: this.routeManager.register(': 7, @@ -757,6 +770,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'security-routes.ts: this.routeManager.register(': 6, 'discovery-routes.ts: this.routeManager.register(': 4, 'email-routes.ts: this.routeManager.register(': 1, + 'search-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 @@ -764,7 +778,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'registerPerItemRoute(': 8, 'const registerPerItemRoute =': 1, 'forwarder slice: this.routeManager.register(': 1, - enforceAuth: 100, + enforceAuth: 104, }, 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 09771b468eb..5e0741783e6 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -106,6 +106,9 @@ const SOURCE = readFileSync(resolve(HERE, 'rest-server.ts'), 'utf8'); * * [#23050 slice 8] …and `registerEmailEndpoints`' body to `email-routes.ts`, * with its one. + * + * [#23050 slice 9] …and `registerSearchEndpoints`' body to `search-routes.ts`, + * with its one. */ const SITE_FILES = REST_SERVER_REGISTRAR_FILES; const SOURCES = new Map(SITE_FILES.map((file) => [ @@ -119,12 +122,13 @@ const siteKey = (file: string, line: number) => `${file}:${line}`; * `registerApprovalsEndpoints`, `registerSharingEndpoints`, * `registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`, * `registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, - * `registerOpenApiEndpoints`, `registerEmailEndpoints`) 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 {@link EXTRA_MOVED_REGISTRARS}, less {@link MOVED_REGISTRARS_READING_NO_EXECCTX} + * `registerOpenApiEndpoints`, `registerEmailEndpoints`, + * `registerSearchEndpoints`) 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 + * {@link EXTRA_MOVED_REGISTRARS}, less {@link MOVED_REGISTRARS_READING_NO_EXECCTX} * — so a respelt forwarder — or a module moved without joining `SITE_FILES` — * is a control failure there rather than every moved site silently attributed * to it. @@ -666,9 +670,19 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { // `rest-server.ts` goes 57 → 58 (one site out, one forwarder's two // mentions in). Ten forwarders now: (9 - 1) + 3 - 1, the module carrying // one registrar that reads the seam, so neither map gains an entry. - expect(SITE_FILES).toHaveLength(9); + // + // [#23050 slice 9] 62 sites UNCHANGED / 117 → 121 mentions — the same MOVE. + // `registerSearchEndpoints`' body left for `search-routes.ts` with its one + // BARE site: 23 + 9 + 5 + 7 + 8 + 1 + 5 + 2 + 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 58 → 59 (one site out, one forwarder's two + // 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); expect(SITES.length).toBe(62); - expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(117); + expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(121); 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 6adbc1f22b5..61b5ba09110 100644 --- a/packages/rest/src/meta-draft-read-door-census.test.ts +++ b/packages/rest/src/meta-draft-read-door-census.test.ts @@ -251,8 +251,10 @@ describe('[#20338] census: every draft switch in rest-server.ts is ledgered', () // length 6 → 7. [#23050 slice 7] Nor do the discovery and OpenAPI bodies // (`discovery-routes.ts`): 8 = 6 + 0 + 0 + 0 + 0 + 2 + 0 + 0, length 7 → 8. // [#23050 slice 8] Nor does the email send body (`email-routes.ts`): - // 8 = 6 + 0 + 0 + 0 + 0 + 2 + 0 + 0 + 0, length 8 → 9. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(9); + // 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); 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 2f83e75662e..1efbc785f83 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -149,6 +149,11 @@ interface CensusEntry { * [#23050 slice 8] 4 = 1 + 3 + 0 + 0 + 0 + 0 + 0 + 0 + 0: the email send body * (`email-routes.ts`) builds no `query:` literal either — it hands the email * service the caller's own JSON body, never a `query:` slot. + * + * [#23050 slice 9] 4 = 1 + 3 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0: the global search + * 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. */ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly> = { 'rest-server.ts': 1, @@ -160,6 +165,7 @@ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly { // [#23050 slice 6] Length 6 → 7, the sum unchanged. // [#23050 slice 7] Length 7 → 8, the sum unchanged. // [#23050 slice 8] Length 8 → 9, the sum unchanged. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(9); + // [#23050 slice 9] Length 9 → 10, the sum unchanged. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(10); 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 45910a472d7..2b8c08900e7 100644 --- a/packages/rest/src/rest-server-docblock-position.test.ts +++ b/packages/rest/src/rest-server-docblock-position.test.ts @@ -170,6 +170,11 @@ describe('rest-server.ts docblock position', () => { * analytics modules do: the module header, the host interface's block and the * function's. The email send body held no TSDoc block of its own, so * `rest-server.ts` keeps every block it had. + * + * [#23050 slice 9] `search-routes.ts` carries three, as the email module does: + * 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). */ const MOVED_FILE_FLOORS: Readonly, number>> = { 'data-action-routes.ts': 4, @@ -180,6 +185,7 @@ const MOVED_FILE_FLOORS: Readonly = REST_SERVER_REGISTRAR_FILES .filter((file): file is Exclude => file !== FILE) @@ -212,9 +218,10 @@ describe('[#23050 slice 3] the registrar list this pin reads', () => { // [#23050 slice 6] 4 + 3 + 3 + 8 + 3 + 8 = 29 over six, length 6 → 7. // [#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. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(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); 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(37); + expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(40); }); }); 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 bbf2d32f76b..3ddf6f8aaac 100644 --- a/packages/rest/src/rest-server-query-number-census.test.ts +++ b/packages/rest/src/rest-server-query-number-census.test.ts @@ -326,8 +326,11 @@ describe('#20139 census — every numeric coercion in rest-server.ts is classifi // the discovery and OpenAPI bodies (`discovery-routes.ts`): // 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0 + 0, length 7 → 8. [#23050 slice 8] Nor // does the email send body (`email-routes.ts`): - // 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0, length 8 → 9. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(9); + // 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0, length 8 → 9. [#23050 slice 9] + // 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); 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 3f7810231b3..12d7337a21c 100644 --- a/packages/rest/src/ui-view-route-identity.measurement.test.ts +++ b/packages/rest/src/ui-view-route-identity.measurement.test.ts @@ -353,7 +353,9 @@ describe('[#13214] §1 the REST seam — absent context vs entitled context', () // neither term, so its literal forwards neither). // [#23050 slice 8] 8 → 9 (`email-routes.ts`): 57 and 72, +1 each for // that slice's forwarder. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(9); + // [#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); 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 ccc34b84b52..28bd8d5d601 100644 --- a/packages/rest/src/ui-view-route-tenancy.measurement.test.ts +++ b/packages/rest/src/ui-view-route-tenancy.measurement.test.ts @@ -1122,7 +1122,9 @@ describe('[#13214] §4 preconditions', () => { // still 1. // [#23050 slice 8] 8 → 9 (`email-routes.ts`): 57 and 72, +1 each for // that slice's forwarder; the ownership guard still 1. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(9); + // [#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); 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 diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index dab834f90fb..fe3ff9bf291 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -370,6 +370,13 @@ const MODULES = { // body's 400 `INVALID_REQUEST`, `'JSON body required'`) left this file with // that body for `email-routes.ts`, declared below at the count it had here; // the sum is unchanged (16 + 13 + 8 + 1 + 0 + 0 + 0 + 0 + 1 = 39). + // + // 16 → 16 (#23050 slice 9): the global search body left this file for + // `search-routes.ts` carrying NO literal string `error` body (its one + // hand-built refusal, the 501 `{ code, message }`, has no `error` key, and + // 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, // 77 → 75 (#7981): registerSecurityEndpoints' two `handleError` arms moved // off the `{ code, error }` sibling-code literal onto the shared @@ -450,6 +457,11 @@ const MODULES = { // `INVALID_REQUEST` and `VALIDATION_FAILED`, and its 500 `EMAIL_SEND_FAILED`) // left with that body for `email-routes.ts`, declared below; the sum is // unchanged (14 + 15 + 8 + 9 + 4 + 1 + 0 + 0 + 3 = 54). + // + // 14 → 14 (#23050 slice 9): the global search body left this file for + // `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, }, @@ -632,6 +644,23 @@ const MODULES = { stringError: 1, siblingCode: 3, }, + + // [#23050 slice 9] `RestServer#registerSearchEndpoints`' body — the global + // cross-object search door, `GET /search` — 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 → 16, siblingCode 14 → 14: + // none), 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/search-routes.ts': { + dialectOnly: + 'moved out of `rest-server.ts` by #23050 slice 9 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: 0, + siblingCode: 0, + }, }; /** Identifiers whose `.json()` READS a request rather than writing a response. */