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/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..7161079aedd --- /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 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 + * `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'], + }, + }); +} 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. */