Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/qa/dogfood/test/authz-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down
28 changes: 21 additions & 7 deletions packages/qa/dogfood/test/authz-probe-blind-spot.census.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -757,14 +770,15 @@ 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
// shape moved and force this provenance to be re-read.
'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: ' +
Expand Down
30 changes: 22 additions & 8 deletions packages/rest/src/execctx-consumer-census.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>(SITE_FILES.map((file) => [
Expand All @@ -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.<member>(...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.<member>(...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.
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions packages/rest/src/meta-draft-read-door-census.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
9 changes: 8 additions & 1 deletion packages/rest/src/rest-server-canonical-query-ast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<RestServerRegistrarFile, number>> = {
'rest-server.ts': 1,
Expand All @@ -160,6 +165,7 @@ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly<Record<RestServerRegistrarFile, numb
'security-routes.ts': 0,
'discovery-routes.ts': 0,
'email-routes.ts': 0,
'search-routes.ts': 0,
};

const CENSUS: CensusEntry[] = [
Expand Down Expand Up @@ -352,7 +358,8 @@ describe('[#16638] §1 CONTROLS on the census instrument itself', () => {
// [#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]);
Expand Down
11 changes: 9 additions & 2 deletions packages/rest/src/rest-server-docblock-position.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<Exclude<RestServerRegistrarFile, typeof FILE>, number>> = {
'data-action-routes.ts': 4,
Expand All @@ -180,6 +185,7 @@ const MOVED_FILE_FLOORS: Readonly<Record<Exclude<RestServerRegistrarFile, typeof
'security-routes.ts': 8,
'discovery-routes.ts': 5,
'email-routes.ts': 3,
'search-routes.ts': 3,
};
const MOVED_FILES: ReadonlyArray<{ file: string; minBlocks: number }> = REST_SERVER_REGISTRAR_FILES
.filter((file): file is Exclude<RestServerRegistrarFile, typeof FILE> => file !== FILE)
Expand Down Expand Up @@ -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);
});
});
7 changes: 5 additions & 2 deletions packages/rest/src/rest-server-query-number-census.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
2 changes: 2 additions & 0 deletions packages/rest/src/rest-server-registrar-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}. */
Expand Down
78 changes: 13 additions & 65 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}

/**
Expand Down
Loading
Loading