Skip to content

Commit 68ffa5d

Browse files
refactor(rest): move registerSearchEndpoints into search-routes.ts (#23050 slice 9) (#23959)
Part of #23050 Clause-②: no Slice 9 of the `rest-server.ts` split, by slice 1's pattern (landed note `6136102437`, steps 1–6), slice 2's getter refinement (`6189011700`) and slices 3–8's shape (PR #23670, PR #23705, PR #23719, PR #23729, PR #23763, PR #23882): the body of `RestServer#registerSearchEndpoints` (`GET {basePath}/search`, the cross-object search door onto the protocol's `searchAll`) moves to ONE new module, `packages/rest/src/search-routes.ts`. Routes, the ordered registrations, every handler's source digest, the published `dist/*.d.ts` and both bundles' export lists are byte-identical, and every reader whose reading included a moved line now reads it where it is, with its sum unchanged. **No token of the body changed.** The body calls no class `static` (AST walk below), so the `static` exception (seat ruling `6205442867`) is not used. It reads no provider field either, so the seat's provider ruling (`6215084478`) does not apply: nothing in the host is a getter. `rest-server.ts` goes from 10,047 to 9,995 lines (base `d5666be876`). The card stays open for the later slices; #23050 remains open after this PR. ## The pattern, applied **Members the moved body reads**, from an AST walk of the method (TypeScript compiler API, symbol-resolved, base `d5666be876`): `registerSearchEndpoints` (JSDoc `:8993`, method `:8997`-`:9063`, body `:8998`-`:9062`, 65 lines). | kind | member | reads | class declaration | |---|---|---|---| | field | `routeManager` | 1, the `register` call (`:8999`) | `:2024`, private | | method | `resolveProtocol` | 1 (`:9005`) | `:2402`, private async | | method | `resolveExecCtx` | 1 (`:9006`) | `:2870`, private async | | method | `enforceAuth` | 1 (`:9007`) | `:2470`, private | No bare `this`, no element access on `this`, no `this`-rebinding `function`, and no `RestServer.NAME` access (static reads: none). The seat's text count (the same four members, one read each) is confirmed. **Control walk** (the same walker, same base, on the neighbouring methods): `resolveSecurityService` (`:8976`, just above) reads `kernelManager` ×2, `resolveRequestEnvironmentId` ×1 and `securityServiceProvider` ×2, which is what its source says; the `registerEmailEndpoints` stub (`:9084`, just below) reads `emailServiceProvider`, `enforceAuth`, `resolveExecCtx` and `routeManager` once each, slice 8's host literal. **Module-level reads** (uses outside the method counted by the same walk): - `GLOBAL_SEARCH_PARAMS` (`:661`, 0 other uses), `UNDECLARED_WHOLE_NUMBER_PARAM` (`:767`, 2) and `readDeclaredQueryNumber` (`:832`, 5): declared in `rest-server.ts` and **already exported** there; the module imports them from `./rest-server.js`. One declaration each, never a copy. No `export` was added anywhere. - `refuseUnknownQueryParams` (`./query-allowlist.js`, 11 other uses), `refuseRepeatedQueryParams` (`./query-multiplicity.js`, 14), `mapDataError` and `logUnexpectedRouteError` (`./error-response.js`, 8 and 6): still used in `rest-server.ts`, so every import stays; the module imports the same names from the same modules. No import moved. **Host shape (slice 1's shape B):** `SearchRouteHost` (4 members) lists exactly the members the body reads, each typed `RestServer['m']`. The class keeps the private method with its name, docblock and position; its body is now a host literal plus `registerSearchEndpoints.call(host, basePath)`. Methods go as late-bound closures, `(...a) => this.m(...a)`. The call site in `registerRoutes`' `registerForBase` (`this.registerSearchEndpoints(bp);`, `:4806`, under `api.enableSearch`) is unchanged. **Fields, checked one by one:** | field | read when | reassigned after registration? | handed over as | |---|---|---|---| | `routeManager` | registration only (one `register` call, a top-level statement of the body) | its one swap (`:5177` / `:5181` at base) wraps `registerMetadataEndpointsInner`, not this registrar | value | No other field is read. The protocol the door searches is resolved per request by `resolveProtocol`, a method, so no getter is owed. **Late binding, measured** (a one-off vitest file, copied in, run under the verify lock, deleted; not committed): a source `RestServer` registers its routes, the door is driven once, then `resolveProtocol`, `resolveExecCtx` and `enforceAuth` are replaced ON THE INSTANCE after `registerRoutes()` and the door is driven again. - head: `MEASURE before: 200 {"hits":["early"]} | after: 200 {"hits":["late"]} | seen early:early-user,late:late-user | late enforceAuth calls 1`, `Tests 1 passed (1)`. - ablation through `scripts/ablation-replace.mjs`: the search host literal's `resolveProtocol: (...a) => this.resolveProtocol(...a),` replaced by `resolveProtocol: this.resolveProtocol.bind(this),` (a three-line anchor naming the literal; anchor 1 → 0, blob `1c7f7e45a921` → `507220e525a3`): `after` stays `{"hits":["early"]}`, `Tests 1 failed (1)`. Restored by the tool: blob `1c7f7e45a921` equals `HEAD`, `git diff HEAD` empty. - base leg (base `rest-server.ts`, blob `2ba4159180`, restored into the tree under a trap, then `git checkout HEAD` with blob and diff proof): the same answers as the head, `Tests 1 passed (1)`. ## Proof of move-only - **`.d.ts`:** `packages/rest/dist/index.d.ts` and `index.d.cts` built at base `d5666be876` (this worktree, before any edit), at the move commit `6a57870fe` and at the merge head `06bd4fcbc` are byte-identical: sha256 `7489de84bc6feccb7cb1f28bcbe4a97775324959c61c9bfc35e8a8c45a6f3bc9` for all, `cmp` silent. The head bundle carries a head-only marker (`registerSearchEndpoints.call`: 2 hits in `index.js`, 0 at base), so the declaration was rebuilt from the moved source, not replayed. `SearchRouteHost` and `search-routes` appear 0 times in the `.d.ts`; `private registerSearchEndpoints;` is still there. `src/index.ts` is untouched. - **Export lists:** the base and merge-head ESM and CJS bundles each load with 39 exports and one sorted export-name list (sha256 `b3246be6a1718700…` on all four). No search-module name is among them. - **Routes:** a source `RestServer` on a recording mock server, with a protocol that answers every member, dumped under slice 7's seven configs (default; scoped-optional; scoped-required; every provider set; `enableDiscovery: false`; `enableOpenApi: false`; both off under scoped-optional) plus an eighth, `enableSearch: false` under scoped-optional. Each dump holds `getRoutes()` (functions as arity), the ordered server registration calls, a whitespace-free sha256 of every handler's source, and the source itself. - **32 of 32 dump files byte-identical**, base `d5666be876` vs merge head `06bd4fcbc` (`diff -r` exits 0). default 75 routes (1 search door), scoped-optional 150 (2), scoped-required 75 (1), every-provider-set equals default, discovery-off 73 (1), openapi-off 73 (1), both-off-scoped-optional 142 (2), search-off-scoped-optional 148 (0). A second base run was identical to the first. - Because no token changed, every handler digest is identical, the search door's included, with no mapping. - **The handler digest can fail:** a positive control added one character to the moved handler (`'Search not supported by this protocol'` → `…protocol!'`, through `ablation-replace.mjs`; anchor 1 → 0, blob `7161079aedd6` → `d2a5eb7f70dd`). Routes and calls were unchanged in all eight configs, and the handler digests differed in exactly the search door of each mounted base (`GET /api/v1/search`, and `GET /api/v1/environments/:environmentId/search` where scoped), nothing else, and nothing under `enableSearch: false`. Restored, blob `7161079aedd6` equals `HEAD`. - **Moved body:** base `rest-server.ts` lines 8998-9062, one 4-space indent removed, against `search-routes.ts` lines 74-138: `diff` exits 0 (65 lines). The body has no empty line, no trailing whitespace, and its one template literal (`${basePath}/search`) sits on one line; every other backtick in the range is in a line comment. - **Moved-line accounting:** `git diff --color-moved=plain --color-moved-ws=allow-indentation-change` base → `6a57870fe` over the two files: all 65 deleted lines are detected as moved. `rest-server.ts` is +13 / −65 in the whole PR: the stub body (host literal plus `.call`, 11 lines in place of 65) and the module's import with its comment (+2). ## Readers re-pointed (sums unchanged, no gate loosened) `packages/rest/src/rest-server-registrar-files.ts` gains `search-routes.ts` with a `[#23050 slice 9]` comment (length 9 → 10). Every reader that pins its length re-reads its number: | reader | sum over the list | change | |---|---|---| | `rest-server-query-number-census.test.ts` | 9 = 8 + 1 + 0 ×8 | the search body READS its two caps through `readDeclaredQueryNumber`, never coerces one; length 9 → 10 | | `rest-server-canonical-query-ast.test.ts` (`repo` project) | 4 = 1 + 3 + 0 ×8 `query:` slots | floor `search-routes.ts: 0`; length 9 → 10 | | `meta-draft-read-door-census.test.ts` | 8 = 6 + 0 + 0 + 0 + 0 + 2 + 0 + 0 + 0 + 0 | the search body reads no draft; length 9 → 10 | | `rest-server-docblock-position.test.ts` | floors 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 + 3 = 40 | floor `search-routes.ts: 3` (header, host interface, function; the body held no TSDoc block); `rest-server.ts` keeps 164 blocks, floor 100; length 9 → 10 | | `execctx-consumer-census.test.ts` | 62 sites = 23 + 9 + 5 + 7 + 8 + 1 + 5 + 2 + 1 + 1 | the search site is BARE and guarded on its next line, as before (46 bare / 16 caught unchanged). Mentions 117 → 121: the seam once (forwarder ×2 in `rest-server.ts`, interface member ×2 in the module); `rest-server.ts` 58 → 59. Forwarders 11 = (10 − 1) + 3 − 1; neither map gains an entry | | `ui-view-route-identity.measurement.test.ts` / `ui-view-route-tenancy.measurement.test.ts` | `this.enforceAuth(` 58, `this.resolveExecCtx(` 73 over the list (57 / 72 before, +1 each for the search literal's forwarders); ownership guard 1 | length 9 → 10 | | dogfood `authz-conformance.test.ts` (realtime tripwires) | 0 hits over the ten files | length 9 → 10 | | dogfood `authz-probe-blind-spot.census.ts`, `rest-server.ts` row | 70 / 19 / 51 unchanged; `this.routeManager.register(` 63 = 21 + 9 + 5 + 7 + 8 + 1 + 6 + 4 + 1 + 1; `private register*Endpoints(` 16 | new control `search-routes.ts: this.routeManager.register(` 1; `registrar list length` 10; `enforceAuth` 100 → 104 re-anchored (the one call site moved and is counted in both readings; the four new occurrences are the seam once; no call site added); `populationRule` names the new module | The module header names none of the three closures in prose, so no census that counts those spellings reads a comment as a seam (a second commit, `6a57870fe`, reworded the first draft's header for exactly that reason). Readers outside the list: - `scripts/check-route-envelope.mjs` (a gate's target paths, `domain:spec`): `search-routes.ts` is discovered by the `*-routes.ts` convention and declared `dialectOnly` with ratchet #9559, as the eight earlier modules are, at its measured `stringError 0` / `siblingCode 0`. Its one hand-built refusal, the 501 `{ code: 'NOT_IMPLEMENTED', message }`, has no `error` key, and its catch sends `mapDataError`'s computed body, so neither rule counts it. `rest-server.ts` stays 16 / 14, so the sums 16 + 13 + 8 + 1 + 0 + 0 + 0 + 0 + 1 + 0 = 39 and 14 + 15 + 8 + 9 + 4 + 1 + 0 + 0 + 3 + 0 = 54 are unchanged (the gate compares both counts exactly, in both directions). Its output differs from `origin/main` only by the new row (19 → 20 modules, 10 → 11 ratcheted). `--self-test` green. - No new ADR anchor: the body cites no `ADR-NNNN Dk`, and `check-adr-anchors` output is identical at `origin/main` and head (1770 decision-letter citations). - No platform-checklist anchor names the search registrar; `check:platform-checklist` output is identical at `origin/main` and head. **Red before the re-points**, each measured with the move in place (`6a57870fe`, the readers at their base text): `@objectstack/rest` `local` read `Test Files 6 failed | 280 passed (286)`, `Tests 7 failed | 5887 passed | 326 skipped (6220)` (six length pins and the docblock pin's unfloored new module); `rest-server-canonical-query-ast.test.ts` (`repo`) read `Tests 2 failed | 244 passed | 10 skipped (256)`; the dogfood pair read `Tests 2 failed | 106 passed (108)` (the realtime tripwire's length pin, and `control "registrar list length" moved … expected 10 to be 9`). `tsc --noEmit` was clean, but `check:test-typecheck` found 2 type errors, the two typed floor tables without the new module. `check:route-envelope` (at `e8386a523`) read `search-routes.ts NOT DECLARED` (MODULES and EXPRESS_RESPONSE_MODULES). Each is green after the re-point. ## Readers examined and not re-pointed - **`packages/spec` (stopped at, per the dispatch, not edited):** no reader there includes a moved line. `packages/spec/liveness/rest_api.json` cites `rest-server.ts#registerRoutes` for the `if (this.config.api.enableSearch)` gate, the call site that did not move. `packages/spec/src/api/rest-server.zod.ts` (about `:154`) and `packages/spec/src/system/core-services.zod.ts` (about `:153`) name the method without a file, and the method still registers the door. `check:liveness` output is identical at `origin/main` and head. - `packages/metadata-protocol/src/protocol.ts` (about `:8304`, another lane's comment) says "`registerSearchEndpoints` (packages/rest/src/rest-server.ts) 501s exactly when `typeof protocol.searchAll !== 'function'`". The 501 line now lives in `search-routes.ts`; the method of that name still registers the door. No gate reads the comment. Not edited. - `rest-server.ts`' `UNDECLARED_WHOLE_NUMBER_PARAM` docblock lists `GET /search` among the doors "in this file"; the export (slice 1) and approvals (slice 3) doors it names left earlier. A module-level const the package entry does not re-export, so not in the `.d.ts`; pre-existing deixis. Not edited. - `GLOBAL_SEARCH_PARAMS` stays declared in `rest-server.ts`, where the ruling keeps helpers; it now has no use in that file (its readers are the module and `rest-server-closed-query-params.test.ts`, both importing it from `./rest-server.js`). - Prose that names the method or the door without a file, still true: `discovery-routes.ts` (about `:392`), `discovery-search-capability-agreement.test.ts`, `query-multiplicity.ts`, `rest-route-ledger.ts` (by route). - `docs/audits/2026-06-record-search-liveness.md` cites `rest-server.ts:3309`, a dated audit line outside the moved range. Released `CHANGELOG.md` text: not edited. - Gates that walk `packages/rest/src` reach the moved code in its new file: their outputs at `origin/main` and head differ only by the one scanned file (`check-meta-type-normalized` 37 → 38, `check-dispatcher-error-vocabulary`, `check-error-status-conformance`, `check-undeclared-dep-imports`), `check-affected-docs` (route sources 27 → 28, registration call sites 22 → 23, by the `*-routes.ts` convention), and `check-scripts-symbol-anchors` (+4 file-level anchors, the gate's new comments). Identical: `check-single-authz-resolver`, `check-system-context-census`, `check-rest-log-declared`, `check:rest-log-spy-declared`, `check-durability-degradation-log-level`, `check-filter-alias-parity`, `check-adr-anchors`, `check-adr-symbol-anchors`, `check:platform-checklist`, `check:liveness`. - No `rest-server.ts:NNNN` line citation in a gated document falls in the moved range (`:8993`-`:9063`). ## Acceptance notes - `objectql-slot-consumer-census.test.ts` (slice 8's extra reader) is not touched: the search body reads no provider slot, and it stays green. - The moved `[#7606]` comment's pointer ("same order and same envelope as the export route — see `query-allowlist.ts`") still reads true; the export route lives in `data-action-routes.ts`. ## Gates and tests Measured at this branch: base `d5666be876`, the move `e8386a523` plus its header wording `6a57870fe`, the re-points `0ba4e5617`, and one merge of `origin/main` `b9dbc3d89` (`06bd4fcbc`: six commits in cli, runtime, plugin-security, spec, lint tests and scripts; no file in this diff). All builds and tests went through `os-verify-lock`. Every exit code was captured to a file before any pipe. **Final head: `06bd4fcbc`.** - `pnpm install --frozen-lockfile` exit 0; `pnpm --workspace-concurrency=2 --filter '@objectstack/rest...' --filter '@objectstack/verify...' build` exit 0 at base and at the merge head; then `pnpm turbo run build --concurrency=2`, the whole workspace, exit 0 (`Tasks: 76 successful, 76 total`), after which the `@objectstack/rest` `.d.ts` / `.d.cts` still equal base. - `pnpm --filter @objectstack/rest typecheck`: exit 0 (`tsc --noEmit`, then `check:test-typecheck`: 0 file(s) / 0 error(s)). - `pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2`: Test Files 286 passed (286), Tests 5894 passed / 326 skipped (6220). - `pnpm --filter @objectstack/rest exec vitest run --project repo --maxWorkers=2`: Test Files 5 passed (5), Tests 246 passed / 10 skipped (256). - `pnpm --filter @objectstack/dogfood exec vitest run test/authz-probe-blind-spot.test.ts test/authz-conformance.test.ts`: Test Files 2 passed (2), Tests 108 passed (108). - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, no paths, at `06bd4fcbc` (stderr names that commit; the `--repo` assertion holds; 13 paths against merge base `b9dbc3d89`, 350 changed lines): 78 commands, all run at `06bd4fcbc` after the whole-workspace build, every one exit 0. `--ran`: `Run reconciliation — 78 derived, 78 run, 0 NOT-MEASURED, 0 UNRUN.` Among them: `pnpm check:route-envelope`, `pnpm --filter @objectstack/spec run check:liveness`, `pnpm check:durability-log-level`, `pnpm check:dispatcher-error-vocabulary`, `pnpm check:dts-closure`, `pnpm check:dual-build-cjs-loads`, `pnpm check:published-files`, `pnpm check:cross-package-test-inputs`, `pnpm check:nul-bytes` and `pnpm check:pm-dispatch-gates`. `check-adr-anchors` and `check-platform-checklist` (not derived) also ran: exit 0, output identical to `origin/main`. - `origin/main` moved on after the merge (to `7277c07f6`, 13 commits): none touches a file in this diff; its one `packages/rest` addition is a new test that drives the `/meta` save door through `RestServer` and reads no source, and its checklist additions add no anchor into `rest-server.ts`. It also adds a gate (`check:pnpm-flag-spellings`) the derivation above could not name; CI's `Lint & Repo Gates` runs it on the merge ref. Not merged again. **Per command (exit code), 78 lines:** ```text node scripts/check-ci-filter-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-declaration-mirrors.mjs --self-test :: exit 0 node scripts/check-declaration-mirrors.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0 node scripts/check-keyed-text-bounds.mjs :: exit 0 node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0 node scripts/check-platform-object-tenancy-census.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-registry-log-declared.mjs --self-test :: exit 0 node scripts/check-registry-log-declared.mjs :: exit 0 node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0 node scripts/check-rest-log-spy-declared.mjs :: exit 0 node scripts/check-scripts-symbol-anchors.mjs --self-test :: exit 0 node scripts/check-scripts-symbol-anchors.mjs :: exit 0 node scripts/check-self-test-wired.mjs --self-test :: exit 0 node scripts/check-self-test-wired.mjs :: exit 0 node scripts/check-self-test-workflow-commands.mjs --self-test :: exit 0 node scripts/check-self-test-workflow-commands.mjs :: exit 0 node scripts/check-system-context-census.mjs --self-test :: exit 0 node scripts/check-system-context-census.mjs :: exit 0 node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0 node scripts/check-undeclared-dep-imports.mjs :: exit 0 node scripts/check-whole-set-label-write.mjs --self-test :: exit 0 node scripts/check-whole-set-label-write.mjs :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/bare-root-worklist.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm --filter @objectstack/spec run check:empty-state :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 pnpm check:agent-test-spelling :: exit 0 pnpm check:authz-resolver :: exit 0 pnpm check:bash32-floor :: exit 0 pnpm check:cli-command-ids :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:durability-log-level :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:entry-guard :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:parse-guard :: exit 0 pnpm check:pm-dispatch-gates :: exit 0 pnpm check:pnpm-filter-targets :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:ratchet-remedy-authority :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:route-envelope :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` - Lint: `pnpm exec eslint --no-inline-config --format json` over the 13 changed files: exit 0, 13 results, 0 errors, 0 warnings. The published `.d.ts` is byte-identical, so no consumer outside `packages/rest` can lint differently. Repo-wide `pnpm lint` is CI's (`Lint & Repo Gates`). - `check:dev-prereqs` and `check:skill-frame-freshness` (local-only) were not run: this diff touches no `packages/spec` build input and no four-axis frame file. - No changeset: nothing any released package publishes changes (`.d.ts`, export lists and routes above). `skip-changeset` is the seat's to apply on its own check. **Cross-lane files in this diff:** `scripts/check-route-envelope.mjs` (`domain:spec`, a gate's target paths: one new MODULES entry and two history comments on the `rest-server.ts` row, counts and sums unchanged). The dogfood files (`packages/qa/dogfood/test/authz-conformance.test.ts`, `authz-probe-blind-spot.census.ts`) are re-pointed as in slices 3–8. `check-governed-merges --test` over the 13 paths: NOT governed; 350 changed lines. --- _Generated by [Claude Code](https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6d1660d commit 68ffa5d

13 files changed

Lines changed: 260 additions & 90 deletions

‎packages/qa/dogfood/test/authz-conformance.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1630,7 +1630,8 @@ describe('#9084 — the rest-server.ts transport tripwire keys off mechanics', (
16301630
// [#23050 slice 6] Length 6 → 7 (`security-routes.ts`), still 0 over it.
16311631
// [#23050 slice 7] Length 7 → 8 (`discovery-routes.ts`), still 0 over it.
16321632
// [#23050 slice 8] Length 8 → 9 (`email-routes.ts`), still 0 over it.
1633-
expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(9);
1633+
// [#23050 slice 9] Length 9 → 10 (`search-routes.ts`), still 0 over it.
1634+
expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(10);
16341635
expect(REST_SERVER_REGISTRAR_PATHS[0]).toBe(REST_SERVER);
16351636
const listed = REST_SERVER_REGISTRAR_PATHS.map((file) => readFileSync(join(REPO_ROOT, file), 'utf8'));
16361637
for (const probe of [MECHANICS, ROUTES]) {

‎packages/qa/dogfood/test/authz-probe-blind-spot.census.ts‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -520,11 +520,12 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
520520
'terminator spelling), PLUS `registerPerItemRoute(` call sites; counted over this file AND every ' +
521521
'module on the registrar list (`packages/rest/src/rest-server-registrar-files.ts`) — ' +
522522
'`data-action-routes.ts`, `batch-routes.ts`, `approvals-routes.ts`, `sharing-routes.ts`, ' +
523-
'`analytics-routes.ts`, `security-routes.ts`, `discovery-routes.ts` and `email-routes.ts`, the modules ' +
524-
'the bodies of `registerDataActionEndpoints`, `registerBatchEndpoints`, `registerApprovalsEndpoints`, ' +
525-
'`registerSharingEndpoints`, `registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`, ' +
526-
'`registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, `registerDiscoveryEndpoints`, ' +
527-
'`registerOpenApiEndpoints` and `registerEmailEndpoints` moved to; ' +
523+
'`analytics-routes.ts`, `security-routes.ts`, `discovery-routes.ts`, `email-routes.ts` and ' +
524+
'`search-routes.ts`, the modules the bodies of `registerDataActionEndpoints`, `registerBatchEndpoints`, ' +
525+
'`registerApprovalsEndpoints`, `registerSharingEndpoints`, `registerSharingRuleEndpoints`, ' +
526+
'`registerAnalyticsEndpoints`, `registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, ' +
527+
'`registerDiscoveryEndpoints`, `registerOpenApiEndpoints`, `registerEmailEndpoints` and ' +
528+
'`registerSearchEndpoints` moved to; ' +
528529
'reachable = those inside registerMetadataEndpoints',
529530
// [#15542 / #15854] ⭐ THE POPULATION RULE LEARNED A SECOND SPELLING, and
530531
// the numbers it produces did NOT move: 80 / 19 / 61, exactly as before.
@@ -745,10 +746,22 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
745746
// new occurrences are this slice's seam, the same two spellings once (the
746747
// host literal's forwarder, two; the host interface's member, two). No call
747748
// site was added.
749+
// [#23050 slice 9] 70 / 19 / 51 UNCHANGED, the same measurement again:
750+
// `registerSearchEndpoints`' body (one registration site, `GET
751+
// {base}/search`, outside `registerMetadataEndpoints`, so in the blind spot)
752+
// moved to `search-routes.ts`, which joins the list (length 9 -> 10) with
753+
// its own control. `this.routeManager.register(` stays 63 over the ten
754+
// files (21 here + 9 + 5 + 7 + 8 + 1 + 6 + 4 + 1 + 1),
755+
// `private register*Endpoints(` stays 16 (the registrar is still declared
756+
// here), and `enforceAuth` 100 -> 104 is RE-ANCHORED, not relaxed: the one
757+
// call site moved with its route and is counted in both readings; the four
758+
// new occurrences are this slice's seam, the same two spellings once (the
759+
// host literal's forwarder, two; the host interface's member, two). No call
760+
// site was added.
748761
controls: {
749762
'private register*Endpoints(': 16,
750763
'this.routeManager.register(': 63,
751-
'registrar list length': 9,
764+
'registrar list length': 10,
752765
'data-action-routes.ts: this.routeManager.register(': 9,
753766
'batch-routes.ts: this.routeManager.register(': 5,
754767
'approvals-routes.ts: this.routeManager.register(': 7,
@@ -757,14 +770,15 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
757770
'security-routes.ts: this.routeManager.register(': 6,
758771
'discovery-routes.ts: this.routeManager.register(': 4,
759772
'email-routes.ts: this.routeManager.register(': 1,
773+
'search-routes.ts: this.routeManager.register(': 1,
760774
// Both halves of the new rule carry their own control, so neither can go
761775
// silently to zero: a helper deleted and its routes inlined back would
762776
// still read population 80, and only these two controls would notice the
763777
// shape moved and force this provenance to be re-read.
764778
'registerPerItemRoute(': 8,
765779
'const registerPerItemRoute =': 1,
766780
'forwarder slice: this.routeManager.register(': 1,
767-
enforceAuth: 100,
781+
enforceAuth: 104,
768782
},
769783
note:
770784
'The single non-tripwire probe names ONE registrar of 16. The other 15 can never mint a key: ' +

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

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ const SOURCE = readFileSync(resolve(HERE, 'rest-server.ts'), 'utf8');
106106
*
107107
* [#23050 slice 8] …and `registerEmailEndpoints`' body to `email-routes.ts`,
108108
* with its one.
109+
*
110+
* [#23050 slice 9] …and `registerSearchEndpoints`' body to `search-routes.ts`,
111+
* with its one.
109112
*/
110113
const SITE_FILES = REST_SERVER_REGISTRAR_FILES;
111114
const SOURCES = new Map<string, string>(SITE_FILES.map((file) => [
@@ -119,12 +122,13 @@ const siteKey = (file: string, line: number) => `${file}:${line}`;
119122
* `registerApprovalsEndpoints`, `registerSharingEndpoints`,
120123
* `registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`,
121124
* `registerSecurityExplainEndpoints`, `registerSecurityEndpoints`,
122-
* `registerOpenApiEndpoints`, `registerEmailEndpoints`) hands its moved body
123-
* forwards each member to the server with `(...a) => this.<member>(...a)`, so
124-
* a moved site's stack shows that forwarder above the site itself. The site is
125-
* the first censused frame that is not one. Found by their spelling, and §2
126-
* pins ONE per moved REGISTRAR whose body reads the seam — one per moved
127-
* module, plus {@link EXTRA_MOVED_REGISTRARS}, less {@link MOVED_REGISTRARS_READING_NO_EXECCTX}
125+
* `registerOpenApiEndpoints`, `registerEmailEndpoints`,
126+
* `registerSearchEndpoints`) hands its moved body forwards each member to the
127+
* server with `(...a) => this.<member>(...a)`, so a moved site's stack shows
128+
* that forwarder above the site itself. The site is the first censused frame
129+
* that is not one. Found by their spelling, and §2 pins ONE per moved
130+
* REGISTRAR whose body reads the seam — one per moved module, plus
131+
* {@link EXTRA_MOVED_REGISTRARS}, less {@link MOVED_REGISTRARS_READING_NO_EXECCTX}
128132
* — so a respelt forwarder — or a module moved without joining `SITE_FILES` —
129133
* is a control failure there rather than every moved site silently attributed
130134
* to it.
@@ -666,9 +670,19 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
666670
// `rest-server.ts` goes 57 → 58 (one site out, one forwarder's two
667671
// mentions in). Ten forwarders now: (9 - 1) + 3 - 1, the module carrying
668672
// one registrar that reads the seam, so neither map gains an entry.
669-
expect(SITE_FILES).toHaveLength(9);
673+
//
674+
// [#23050 slice 9] 62 sites UNCHANGED / 117 → 121 mentions — the same MOVE.
675+
// `registerSearchEndpoints`' body left for `search-routes.ts` with its one
676+
// BARE site: 23 + 9 + 5 + 7 + 8 + 1 + 5 + 2 + 1 + 1 = 62 sites. The four
677+
// new mentions are this slice's seam, the two spellings once (its host
678+
// literal's forwarder, two; its host interface's member, two):
679+
// `rest-server.ts` goes 58 → 59 (one site out, one forwarder's two
680+
// mentions in). Eleven forwarders now: (10 - 1) + 3 - 1, the module
681+
// carrying one registrar that reads the seam, so neither map gains an
682+
// entry.
683+
expect(SITE_FILES).toHaveLength(10);
670684
expect(SITES.length).toBe(62);
671-
expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(117);
685+
expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(121);
672686
const extraMovedRegistrars = Object.values(EXTRA_MOVED_REGISTRARS).reduce((n, k) => n + (k ?? 0), 0);
673687
expect(extraMovedRegistrars).toBe(3);
674688
const registrarsReadingNoExecCtx = Object.values(MOVED_REGISTRARS_READING_NO_EXECCTX).reduce((n, k) => n + (k ?? 0), 0);

‎packages/rest/src/meta-draft-read-door-census.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,8 +251,10 @@ describe('[#20338] census: every draft switch in rest-server.ts is ledgered', ()
251251
// length 6 → 7. [#23050 slice 7] Nor do the discovery and OpenAPI bodies
252252
// (`discovery-routes.ts`): 8 = 6 + 0 + 0 + 0 + 0 + 2 + 0 + 0, length 7 → 8.
253253
// [#23050 slice 8] Nor does the email send body (`email-routes.ts`):
254-
// 8 = 6 + 0 + 0 + 0 + 0 + 2 + 0 + 0 + 0, length 8 → 9.
255-
expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(9);
254+
// 8 = 6 + 0 + 0 + 0 + 0 + 2 + 0 + 0 + 0, length 8 → 9. [#23050 slice 9]
255+
// Nor does the global search body (`search-routes.ts`):
256+
// 8 = 6 + 0 + 0 + 0 + 0 + 2 + 0 + 0 + 0 + 0, length 9 → 10.
257+
expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(10);
256258
expect(SOURCE_FILES.map((file) => file.fileName)).toEqual([...REST_SERVER_REGISTRAR_FILES]);
257259
expect(SITES).toHaveLength(8);
258260
});

‎packages/rest/src/rest-server-canonical-query-ast.test.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,11 @@ interface CensusEntry {
149149
* [#23050 slice 8] 4 = 1 + 3 + 0 + 0 + 0 + 0 + 0 + 0 + 0: the email send body
150150
* (`email-routes.ts`) builds no `query:` literal either — it hands the email
151151
* service the caller's own JSON body, never a `query:` slot.
152+
*
153+
* [#23050 slice 9] 4 = 1 + 3 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0: the global search
154+
* body (`search-routes.ts`) builds no `query:` literal either — it hands
155+
* `searchAll` a `q` / `objects` / `limit` / `perObject` request read off
156+
* `req.query`, never a `query:` slot.
152157
*/
153158
const REGISTRAR_QUERY_SLOT_FLOORS: Readonly<Record<RestServerRegistrarFile, number>> = {
154159
'rest-server.ts': 1,
@@ -160,6 +165,7 @@ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly<Record<RestServerRegistrarFile, numb
160165
'security-routes.ts': 0,
161166
'discovery-routes.ts': 0,
162167
'email-routes.ts': 0,
168+
'search-routes.ts': 0,
163169
};
164170

165171
const CENSUS: CensusEntry[] = [
@@ -352,7 +358,8 @@ describe('[#16638] §1 CONTROLS on the census instrument itself', () => {
352358
// [#23050 slice 6] Length 6 → 7, the sum unchanged.
353359
// [#23050 slice 7] Length 7 → 8, the sum unchanged.
354360
// [#23050 slice 8] Length 8 → 9, the sum unchanged.
355-
expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(9);
361+
// [#23050 slice 9] Length 9 → 10, the sum unchanged.
362+
expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(10);
356363
expect(Object.keys(REGISTRAR_QUERY_SLOT_FLOORS).sort()).toEqual([...REST_SERVER_REGISTRAR_FILES].sort());
357364
expect(CENSUS.filter(({ file }) => REGISTRAR_SOURCES.has(file)).map(({ file }) => file))
358365
.toEqual([...REST_SERVER_REGISTRAR_FILES]);

‎packages/rest/src/rest-server-docblock-position.test.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,11 @@ describe('rest-server.ts docblock position', () => {
170170
* analytics modules do: the module header, the host interface's block and the
171171
* function's. The email send body held no TSDoc block of its own, so
172172
* `rest-server.ts` keeps every block it had.
173+
*
174+
* [#23050 slice 9] `search-routes.ts` carries three, as the email module does:
175+
* the module header, the host interface's block and the function's. The global
176+
* search body held no TSDoc block of its own, so `rest-server.ts` keeps every
177+
* block it had (164).
173178
*/
174179
const MOVED_FILE_FLOORS: Readonly<Record<Exclude<RestServerRegistrarFile, typeof FILE>, number>> = {
175180
'data-action-routes.ts': 4,
@@ -180,6 +185,7 @@ const MOVED_FILE_FLOORS: Readonly<Record<Exclude<RestServerRegistrarFile, typeof
180185
'security-routes.ts': 8,
181186
'discovery-routes.ts': 5,
182187
'email-routes.ts': 3,
188+
'search-routes.ts': 3,
183189
};
184190
const MOVED_FILES: ReadonlyArray<{ file: string; minBlocks: number }> = REST_SERVER_REGISTRAR_FILES
185191
.filter((file): file is Exclude<RestServerRegistrarFile, typeof FILE> => file !== FILE)
@@ -212,9 +218,10 @@ describe('[#23050 slice 3] the registrar list this pin reads', () => {
212218
// [#23050 slice 6] 4 + 3 + 3 + 8 + 3 + 8 = 29 over six, length 6 → 7.
213219
// [#23050 slice 7] 4 + 3 + 3 + 8 + 3 + 8 + 5 = 34 over seven, length 7 → 8.
214220
// [#23050 slice 8] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 = 37 over eight, length 8 → 9.
215-
expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(9);
221+
// [#23050 slice 9] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 + 3 = 40 over nine, length 9 → 10.
222+
expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(10);
216223
expect(Object.keys(MOVED_FILE_FLOORS).sort())
217224
.toEqual(REST_SERVER_REGISTRAR_FILES.filter((file) => file !== FILE).sort());
218-
expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(37);
225+
expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(40);
219226
});
220227
});

‎packages/rest/src/rest-server-query-number-census.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,8 +326,11 @@ describe('#20139 census — every numeric coercion in rest-server.ts is classifi
326326
// the discovery and OpenAPI bodies (`discovery-routes.ts`):
327327
// 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0 + 0, length 7 → 8. [#23050 slice 8] Nor
328328
// does the email send body (`email-routes.ts`):
329-
// 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0, length 8 → 9.
330-
expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(9);
329+
// 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0, length 8 → 9. [#23050 slice 9]
330+
// Nor does the global search body (`search-routes.ts`): it READS its two
331+
// caps through `readDeclaredQueryNumber`, never coerces one:
332+
// 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0, length 9 → 10.
333+
expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(10);
331334
expect(SOURCES.map(([file]) => file)).toEqual([...REST_SERVER_REGISTRAR_FILES]);
332335
expect(sites).toHaveLength(9);
333336
});

‎packages/rest/src/rest-server-registrar-files.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export const REST_SERVER_REGISTRAR_FILES = [
4545
'discovery-routes.ts',
4646
// [#23050 slice 8] `registerEmailEndpoints`' body.
4747
'email-routes.ts',
48+
// [#23050 slice 9] `registerSearchEndpoints`' body.
49+
'search-routes.ts',
4850
] as const;
4951

5052
/** One file on {@link REST_SERVER_REGISTRAR_FILES}. */

‎packages/rest/src/rest-server.ts‎

Lines changed: 13 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,8 @@ import {
341341
} from './discovery-routes.js';
342342
// [#23050 slice 8] The email route's body moved to its own module — see its header.
343343
import { registerEmailEndpoints, type EmailRouteHost } from './email-routes.js';
344+
// [#23050 slice 9] The global search route's body moved to its own module — see its header.
345+
import { registerSearchEndpoints, type SearchRouteHost } from './search-routes.js';
344346
import { loadExcelJs, type Worksheet } from './xlsx-module.js';
345347
// [#18386] `?template=true` on the export door: the import template's column
346348
// rule, request reading and workbook. See the module header.
@@ -8995,71 +8997,17 @@ export class RestServer {
89958997
* GET {basePath}/search?q=acme&objects=lead,account&limit=20&perObject=5
89968998
*/
89978999
private registerSearchEndpoints(basePath: string): void {
8998-
const isScoped = basePath.includes('/environments/:environmentId');
8999-
this.routeManager.register({
9000-
method: 'GET',
9001-
path: `${basePath}/search`,
9002-
handler: async (req: any, res: any) => {
9003-
try {
9004-
const environmentId = isScoped ? req.params?.environmentId : undefined;
9005-
const p = await this.resolveProtocol(environmentId, req);
9006-
const context = await this.resolveExecCtx(environmentId, req);
9007-
if (this.enforceAuth(req, res, context)) return;
9008-
const searchAll = (p as any).searchAll;
9009-
if (typeof searchAll !== 'function') {
9010-
res.status(501).json({ code: 'NOT_IMPLEMENTED', message: 'Search not supported by this protocol' });
9011-
return;
9012-
}
9013-
// [#6877] `String(['a','b'])` searched for the literal
9014-
// term `'a,b'`. `objects` is NOT listed: the next line reads
9015-
// its array arm deliberately — a cross-object search over a
9016-
// LIST of objects is the whole point of the parameter.
9017-
// [#7606] Recognition before arity, same order and same
9018-
// envelope as the export route — see `query-allowlist.ts`.
9019-
// A dropped `?objects=` is the widening case at its worst:
9020-
// the term fans out across every searchable object while the
9021-
// caller believes they scoped it to one.
9022-
if (refuseUnknownQueryParams(req, res, GLOBAL_SEARCH_PARAMS)) return;
9023-
if (refuseRepeatedQueryParams(req, res, ['q', 'query', 'limit', 'perObject'])) return;
9024-
const q = String(req.query?.q ?? req.query?.query ?? '');
9025-
const objectsParam = req.query?.objects;
9026-
const objects = typeof objectsParam === 'string'
9027-
? objectsParam.split(',').map((s: string) => s.trim()).filter(Boolean)
9028-
: Array.isArray(objectsParam) ? objectsParam : undefined;
9029-
// [#20062] Read, not coerced: `?limit=abc` used to reach
9030-
// `searchAll` as `NaN`, and `hits.length >= NaN` is never true,
9031-
// so the overall cap was silently gone. No request schema is
9032-
// declared for this door, so the reading is "a whole number";
9033-
// `searchAll`'s own `[1, 100]` clamp is unchanged, and an empty
9034-
// `?limit=` stays absent as the old falsy guard had it.
9035-
const limit = readDeclaredQueryNumber(req.query, 'limit',
9036-
UNDECLARED_WHOLE_NUMBER_PARAM, { emptyIsAbsent: true });
9037-
// [#20139] `perObject`, the same way: `?perObject=abc` reached
9038-
// `searchAll` as `NaN`, its `Math.max(1, Math.min(25, NaN))` is
9039-
// `NaN`, and the per-object cap was silently gone. Whole number;
9040-
// the `[1, 25]` clamp stays `searchAll`'s own, and an empty
9041-
// `?perObject=` stays absent as the old falsy guard had it.
9042-
const perObject = readDeclaredQueryNumber(req.query, 'perObject',
9043-
UNDECLARED_WHOLE_NUMBER_PARAM, { emptyIsAbsent: true });
9044-
const result = await searchAll.call(p, {
9045-
q,
9046-
objects,
9047-
limit,
9048-
perObject,
9049-
...(context ? { context } : {}),
9050-
});
9051-
res.json(result);
9052-
} catch (error: any) {
9053-
const mapped = mapDataError(error);
9054-
logUnexpectedRouteError(error, mapped);
9055-
res.status(mapped.status).json(mapped.body);
9056-
}
9057-
},
9058-
metadata: {
9059-
summary: 'Global cross-object search',
9060-
tags: ['search'],
9061-
},
9062-
});
9000+
// [#23050 slice 9] The body moved to `search-routes.ts`. This literal
9001+
// hands it every member it reads — the list IS its dependency on this
9002+
// class (see `SearchRouteHost`, and that module's header for why no
9003+
// member is a getter).
9004+
const host: SearchRouteHost = {
9005+
routeManager: this.routeManager,
9006+
resolveProtocol: (...a) => this.resolveProtocol(...a),
9007+
resolveExecCtx: (...a) => this.resolveExecCtx(...a),
9008+
enforceAuth: (...a) => this.enforceAuth(...a),
9009+
};
9010+
registerSearchEndpoints.call(host, basePath);
90639011
}
90649012

90659013
/**

0 commit comments

Comments
 (0)