From 679fe5b05e44967cbeb819c70088e0b538a1c596 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 10:19:51 +0000 Subject: [PATCH 1/2] refactor(rest): move registerEmailEndpoints' body into email-routes.ts Slice 8 of the rest-server.ts split, move only: the body of RestServer#registerEmailEndpoints (POST {basePath}/email/send) moves, re-indented one level and otherwise unedited, to email-routes.ts. The class keeps the private method and its docblock as a host literal plus .call; routeManager goes by value, emailServiceProvider as a getter. seamOrUndefined, the one helper declared in rest-server.ts that the body reads, is exported from there. The registrar list gains the module. Readers are re-pointed in the next commit. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude --- packages/rest/src/email-routes.ts | 164 ++++++++++++++++++ .../rest/src/rest-server-registrar-files.ts | 2 + packages/rest/src/rest-server.ts | 110 ++---------- 3 files changed, 181 insertions(+), 95 deletions(-) create mode 100644 packages/rest/src/email-routes.ts diff --git a/packages/rest/src/email-routes.ts b/packages/rest/src/email-routes.ts new file mode 100644 index 00000000000..30a8f5a0c88 --- /dev/null +++ b/packages/rest/src/email-routes.ts @@ -0,0 +1,164 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23050 slice 8] The email route of `RestServer` — + * `POST {basePath}/email/send`, the transactional send door onto the + * `IEmailService` provider — moved out of `rest-server.ts` whole. The + * registrar runs once per mounted base, unconditionally, so an + * environment-scoped deployment mounts the door twice. + * + * A MOVE, not a redesign. The function below is the body of + * `RestServer#registerEmailEndpoints` 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 EmailRouteHost}) 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 field 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. + * - `emailServiceProvider` goes as a GETTER. Both of the body's reads of it + * (the wiring guard and the provider call) are inside the handler, so they + * happen at REQUEST time, and the field is not `readonly`, so a value copied + * at registration could part from it. The getter reads the field the handler + * read, when it read it. + * + * Module-level helpers stay where they were. `logError` is imported from the + * module `rest-server.ts` imports it from. The one helper declared in + * `rest-server.ts` itself, `seamOrUndefined` (the provider-seam normalisation + * its docblock there explains), is exported from there and imported here: one + * declaration, never a copy. The package entry does not re-export it, so the + * published surface does not move. + */ + +import { logError } from './log.js'; +import { seamOrUndefined, type RestServer } from './rest-server.js'; + +/** + * The `RestServer` members {@link registerEmailEndpoints} 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 `emailServiceProvider` is a getter. + */ +export interface EmailRouteHost { + readonly routeManager: RestServer['routeManager']; + readonly emailServiceProvider: RestServer['emailServiceProvider']; + resolveExecCtx: RestServer['resolveExecCtx']; + enforceAuth: RestServer['enforceAuth']; +} + +/** + * Register the transactional email send endpoint — + * `RestServer#registerEmailEndpoints`' 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 request body. + */ +export function registerEmailEndpoints(this: EmailRouteHost, basePath: string): void { + const isScoped = basePath.includes('/environments/:environmentId'); + this.routeManager.register({ + method: 'POST', + path: `${basePath}/email/send`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + + if (!this.emailServiceProvider) { + res.status(501).json({ + code: 'NOT_IMPLEMENTED', + message: 'Email service is not configured on this deployment', + }); + return; + } + // [#15405] `seamOrUndefined`, not the retired + // `.catch(() => undefined)`. That handler attaches to the + // promise the call RETURNS, so it can only ever see a + // REJECTION: a host wiring a non-`async` provider — which + // the seam's declared type cannot prevent, and + // `RestServer`'s constructor is the public wiring point — + // throws while the expression is still being evaluated, so + // there is no promise to attach to and the handler is never + // reached (commit add6a1b1c). + // + // ⚠️ NOT reachable from the SHIPPED wiring: the provider + // `rest-api-plugin.ts` hands over is declared `async`. + // Repaired because it is the same retired spelling at an + // embedder-reachable seam, ⛔ not on a claim of live impact. + // + // The ANSWER is deliberately unchanged — absorb to + // `undefined` and take the 501 below. ⛔ Unlike the engine + // seam, this one must NOT go loud: the + // `if (!this.emailServiceProvider)` guard above has already + // answered the wiring question, and "not configured" and + // "configured and unusable" both mean this deployment cannot + // send mail — one 501, no fact lost by folding them. + const emailService = await seamOrUndefined(() => this.emailServiceProvider!(environmentId)); + if (!emailService || typeof emailService.send !== 'function') { + res.status(501).json({ + code: 'NOT_IMPLEMENTED', + message: 'Email service is not configured on this deployment', + }); + return; + } + + const body = req.body ?? {}; + if (!body || typeof body !== 'object') { + res.status(400).json({ code: 'INVALID_REQUEST', error: 'JSON body required' }); + return; + } + // Stamp sentBy from the authenticated context when caller didn't supply one. + const input = { + ...body, + ...(body.sentBy === undefined && (context as any)?.userId + ? { sentBy: (context as any).userId } + : {}), + }; + + try { + const result = await emailService.send(input); + if (result?.status === 'sent') { + res.status(200).json(result); + } else { + // failed / queued — still surface to client with 200 so clients can branch on status. + res.status(200).json(result); + } + } catch (err: any) { + // Validation errors from normalizeMessage are surfaced as 400. + const message = String(err?.message ?? err ?? 'send failed'); + if (message.startsWith('VALIDATION_FAILED')) { + res.status(400).json({ + code: 'VALIDATION_FAILED', + error: message.replace(/^VALIDATION_FAILED:\s*/, ''), + }); + return; + } + throw err; + } + } catch (error: any) { + logError('[REST] Email send unhandled error:', error); + res.status(500).json({ + code: 'EMAIL_SEND_FAILED', + error: String(error?.message ?? error ?? 'send failed').slice(0, 500), + }); + } + }, + metadata: { + summary: 'Send a transactional email via the configured EmailService', + tags: ['email'], + }, + }); +} diff --git a/packages/rest/src/rest-server-registrar-files.ts b/packages/rest/src/rest-server-registrar-files.ts index 0094ef9c013..2eca0e47541 100644 --- a/packages/rest/src/rest-server-registrar-files.ts +++ b/packages/rest/src/rest-server-registrar-files.ts @@ -43,6 +43,8 @@ export const REST_SERVER_REGISTRAR_FILES = [ 'security-routes.ts', // [#23050 slice 7] `registerDiscoveryEndpoints`' and `registerOpenApiEndpoints`' bodies. 'discovery-routes.ts', + // [#23050 slice 8] `registerEmailEndpoints`' body. + 'email-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 730c2aaa03f..2ba4159180a 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -339,6 +339,8 @@ import { type DiscoveryRouteHost, type OpenApiRouteHost, } 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'; 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. @@ -1490,7 +1492,7 @@ export interface MountedRoute extends RouteEntry { * directions, so they are already symmetric and giving them a swallow would be * a new policy rather than a normalisation. */ -async function seamOrUndefined(call: () => T | PromiseLike): Promise { +export async function seamOrUndefined(call: () => T | PromiseLike): Promise { try { return await call(); } catch { @@ -9080,100 +9082,18 @@ export class RestServer { * } */ private registerEmailEndpoints(basePath: string): void { - const isScoped = basePath.includes('/environments/:environmentId'); - this.routeManager.register({ - method: 'POST', - path: `${basePath}/email/send`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - - if (!this.emailServiceProvider) { - res.status(501).json({ - code: 'NOT_IMPLEMENTED', - message: 'Email service is not configured on this deployment', - }); - return; - } - // [#15405] `seamOrUndefined`, not the retired - // `.catch(() => undefined)`. That handler attaches to the - // promise the call RETURNS, so it can only ever see a - // REJECTION: a host wiring a non-`async` provider — which - // the seam's declared type cannot prevent, and - // `RestServer`'s constructor is the public wiring point — - // throws while the expression is still being evaluated, so - // there is no promise to attach to and the handler is never - // reached (commit add6a1b1c). - // - // ⚠️ NOT reachable from the SHIPPED wiring: the provider - // `rest-api-plugin.ts` hands over is declared `async`. - // Repaired because it is the same retired spelling at an - // embedder-reachable seam, ⛔ not on a claim of live impact. - // - // The ANSWER is deliberately unchanged — absorb to - // `undefined` and take the 501 below. ⛔ Unlike the engine - // seam, this one must NOT go loud: the - // `if (!this.emailServiceProvider)` guard above has already - // answered the wiring question, and "not configured" and - // "configured and unusable" both mean this deployment cannot - // send mail — one 501, no fact lost by folding them. - const emailService = await seamOrUndefined(() => this.emailServiceProvider!(environmentId)); - if (!emailService || typeof emailService.send !== 'function') { - res.status(501).json({ - code: 'NOT_IMPLEMENTED', - message: 'Email service is not configured on this deployment', - }); - return; - } - - const body = req.body ?? {}; - if (!body || typeof body !== 'object') { - res.status(400).json({ code: 'INVALID_REQUEST', error: 'JSON body required' }); - return; - } - // Stamp sentBy from the authenticated context when caller didn't supply one. - const input = { - ...body, - ...(body.sentBy === undefined && (context as any)?.userId - ? { sentBy: (context as any).userId } - : {}), - }; - - try { - const result = await emailService.send(input); - if (result?.status === 'sent') { - res.status(200).json(result); - } else { - // failed / queued — still surface to client with 200 so clients can branch on status. - res.status(200).json(result); - } - } catch (err: any) { - // Validation errors from normalizeMessage are surfaced as 400. - const message = String(err?.message ?? err ?? 'send failed'); - if (message.startsWith('VALIDATION_FAILED')) { - res.status(400).json({ - code: 'VALIDATION_FAILED', - error: message.replace(/^VALIDATION_FAILED:\s*/, ''), - }); - return; - } - throw err; - } - } catch (error: any) { - logError('[REST] Email send unhandled error:', error); - res.status(500).json({ - code: 'EMAIL_SEND_FAILED', - error: String(error?.message ?? error ?? 'send failed').slice(0, 500), - }); - } - }, - metadata: { - summary: 'Send a transactional email via the configured EmailService', - tags: ['email'], - }, - }); + // [#23050 slice 8] The body moved to `email-routes.ts`. This literal + // hands it every member it reads — the list IS its dependency on this + // class (see `EmailRouteHost`, and that module's header for why + // `emailServiceProvider` is a getter). + const readEmailServiceProvider = () => this.emailServiceProvider; + const host: EmailRouteHost = { + routeManager: this.routeManager, + get emailServiceProvider() { return readEmailServiceProvider(); }, + resolveExecCtx: (...a) => this.resolveExecCtx(...a), + enforceAuth: (...a) => this.enforceAuth(...a), + }; + registerEmailEndpoints.call(host, basePath); } /** From 626eb4c15447c70d728558b1eeb38655f92decf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 10:30:44 +0000 Subject: [PATCH 2/2] test(rest): re-point the readers of the moved email send body Every reader whose reading included a line of registerEmailEndpoints' body now reads it in email-routes.ts, each with its sum unchanged: - the registrar-list readers re-read their numbers at length 9 (query-number census, canonical query-AST census, draft-door census, docblock-position pin, execctx consumer census, the two ui-view measurements, the dogfood tripwires and blind-spot census row); - the objectQLProvider census reads its emailServiceProvider controls in email-routes.ts, where the slot's two reads and one call now are; - check-route-envelope declares email-routes.ts at stringError 1 / siblingCode 3, and rest-server.ts drops 17 -> 16 / 17 -> 14 (sums 39 and 54 unchanged); - the integration-system checklist item's source and verify prose name email-routes.ts. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude --- .../areas/integration-system.json | 4 +-- .../qa/dogfood/test/authz-conformance.test.ts | 3 +- .../test/authz-probe-blind-spot.census.ts | 25 ++++++++++---- .../rest/src/execctx-consumer-census.test.ts | 28 +++++++++++----- .../src/meta-draft-read-door-census.test.ts | 4 ++- .../src/objectql-slot-consumer-census.test.ts | 20 ++++++++--- .../rest-server-canonical-query-ast.test.ts | 8 ++++- .../src/rest-server-docblock-position.test.ts | 11 +++++-- .../rest-server-query-number-census.test.ts | 6 ++-- ...ui-view-route-identity.measurement.test.ts | 4 ++- .../ui-view-route-tenancy.measurement.test.ts | 4 ++- scripts/check-route-envelope.mjs | 33 +++++++++++++++++-- 12 files changed, 118 insertions(+), 32 deletions(-) diff --git a/docs/qa/platform-checklist/areas/integration-system.json b/docs/qa/platform-checklist/areas/integration-system.json index d1883b15a81..bb5ba56a835 100644 --- a/docs/qa/platform-checklist/areas/integration-system.json +++ b/docs/qa/platform-checklist/areas/integration-system.json @@ -856,7 +856,7 @@ { "clause": "the raw transactional send door (POST /api/v1/email/send → IEmailService.send, complementary to the sendTemplate path above) authenticates and validates: an AUTHED well-formed message — { to, subject } plus AT LEAST ONE of `html` / `text` — lands at the dev transport (200 with result.status 'sent'); an ANONYMOUS send is refused 401 UNAUTHENTICATED (the #3963 unconditional gate — the api.requireAuth opt-out is retired); a MALFORMED body is refused 400 with a ledgered envelope code (INVALID_REQUEST for a non-object body, VALIDATION_FAILED for a bad message shape) — never a 500 for caller-fixable input, and a runtime with no email provider answers 501 NOT_IMPLEMENTED rather than a fake success", "oracle": "api", - "verify": "the three POST /api/v1/email/send responses: authed 200 + dev-transport capture, anonymous 401 UNAUTHENTICATED, malformed 400 with the named code (rest-server.ts registerEmailEndpoints: enforceAuth, non-object→400 INVALID_REQUEST, VALIDATION_FAILED passthrough, 501 no-provider). ⚠ WIRE KEYS (corrected, run #7690): the send input is SendEmailInput { to, subject, text?, html?, from?, cc?, bcc?, replyTo?, … } (packages/spec/src/contracts/email-service.ts) — NOT `bodyHtml`. `bodyHtml`/`bodyText` are the email-TEMPLATE authoring fields (this item's own variants list at the `body:` and alias lines says so); posting them to this door is refused 400 'VALIDATION_FAILED: at least one of text or html is required' (packages/plugins/plugin-email/src/email-service.ts). A 400 here is the run using the wrong vocabulary, not a product defect — re-send with `html` before filing anything", + "verify": "the three POST /api/v1/email/send responses: authed 200 + dev-transport capture, anonymous 401 UNAUTHENTICATED, malformed 400 with the named code (email-routes.ts registerEmailEndpoints: enforceAuth, non-object→400 INVALID_REQUEST, VALIDATION_FAILED passthrough, 501 no-provider). ⚠ WIRE KEYS (corrected, run #7690): the send input is SendEmailInput { to, subject, text?, html?, from?, cc?, bcc?, replyTo?, … } (packages/spec/src/contracts/email-service.ts) — NOT `bodyHtml`. `bodyHtml`/`bodyText` are the email-TEMPLATE authoring fields (this item's own variants list at the `body:` and alias lines says so); posting them to this door is refused 400 'VALIDATION_FAILED: at least one of text or html is required' (packages/plugins/plugin-email/src/email-service.ts). A 400 here is the run using the wrong vocabulary, not a product defect — re-send with `html` before filing anything", "evidence": "the three responses + the dev-transport landing (plus the wrong-vocabulary refusal, if the probe was driven)" } ], @@ -877,7 +877,7 @@ "packages/spec/liveness/email_template.json#requireVars (the whole surface live via #4509; per-prop evidence incl. requireVars, TEMPLATE_INACTIVE, seed-not-clobber, runtime re-materialization)", "packages/plugins/plugin-email/src/email-service.ts ((name, locale) + fallback through the registry TemplateLoader; the requireVars and TEMPLATE_INACTIVE gates) + email-template-door.ts (the closed sys_email_template organization door, ADR-0131 D6) — the row projector bootstrap-declared-email-templates.ts was retired with the table projection (#22875)", "examples/app-showcase/src/system/emails/index.ts#showcase_task_done_email (showcase_task_done_email fixture)", - "packages/rest/src/rest-server.ts#registerEmailEndpoints (registerEmailEndpoints — POST /api/v1/email/send: enforceAuth 401 UNAUTHENTICATED #3963, non-object→400 INVALID_REQUEST, VALIDATION_FAILED passthrough, 501 no-provider, 500 EMAIL_SEND_FAILED)", + "packages/rest/src/email-routes.ts#registerEmailEndpoints (registerEmailEndpoints — POST /api/v1/email/send: enforceAuth 401 UNAUTHENTICATED #3963, non-object→400 INVALID_REQUEST, VALIDATION_FAILED passthrough, 501 no-provider, 500 EMAIL_SEND_FAILED)", "packages/rest/src/rest-route-ledger.ts (email family — POST /api/v1/email/send → client email.send)", "packages/spec/src/api/error-code-ledger.zod.ts#EMAIL_SEND_FAILED (EMAIL_SEND_FAILED under @objectstack/rest)", "packages/spec/src/contracts/email-service.ts#SendEmailInput (SendEmailInput — the WIRE shape of POST /api/v1/email/send: to, subject, text?, html?; 'at least one of text or html must be supplied') + packages/plugins/plugin-email/src/email-service.ts#normalizeMessage (normalizeMessage — where the VALIDATION_FAILED texts come from)" diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index 218f758854f..8f451519976 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -1629,7 +1629,8 @@ describe('#9084 — the rest-server.ts transport tripwire keys off mechanics', ( // [#23050 slice 5] Length 5 → 6 (`analytics-routes.ts`), still 0 over it. // [#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. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(8); + // [#23050 slice 8] Length 8 → 9 (`email-routes.ts`), still 0 over it. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(9); 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 c4bd9c9931a..6c81d64cde1 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,11 @@ 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` and `discovery-routes.ts`, the modules the bodies of ' + - '`registerDataActionEndpoints`, `registerBatchEndpoints`, `registerApprovalsEndpoints`, ' + + '`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` and ' + - '`registerOpenApiEndpoints` moved to; ' + + '`registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, `registerDiscoveryEndpoints`, ' + + '`registerOpenApiEndpoints` and `registerEmailEndpoints` 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. @@ -733,10 +733,22 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // the OpenAPI body reads `enforceAuth` (its host literal's forwarder, two; // its host interface's member, two) — the discovery document is served to // anyone, so its literal forwards no `enforceAuth`. No call site was added. + // [#23050 slice 8] 70 / 19 / 51 UNCHANGED, the same measurement again: + // `registerEmailEndpoints`' body (one registration site, `POST + // {base}/email/send`, outside `registerMetadataEndpoints`, so in the blind + // spot) moved to `email-routes.ts`, which joins the list (length 8 -> 9) + // with its own control. `this.routeManager.register(` stays 63 over the + // nine files (22 here + 9 + 5 + 7 + 8 + 1 + 6 + 4 + 1), + // `private register*Endpoints(` stays 16 (the registrar is still declared + // here), and `enforceAuth` 96 -> 100 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': 8, + 'registrar list length': 9, 'data-action-routes.ts: this.routeManager.register(': 9, 'batch-routes.ts: this.routeManager.register(': 5, 'approvals-routes.ts: this.routeManager.register(': 7, @@ -744,6 +756,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'analytics-routes.ts: this.routeManager.register(': 1, 'security-routes.ts: this.routeManager.register(': 6, 'discovery-routes.ts: this.routeManager.register(': 4, + 'email-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 @@ -751,7 +764,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'registerPerItemRoute(': 8, 'const registerPerItemRoute =': 1, 'forwarder slice: this.routeManager.register(': 1, - enforceAuth: 96, + enforceAuth: 100, }, 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 22794983124..09771b468eb 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -103,6 +103,9 @@ const SOURCE = readFileSync(resolve(HERE, 'rest-server.ts'), 'utf8'); * (none and two) — the third module to carry TWO registrars, and the first * moved registrar whose body reads no `resolveExecCtx` at all (the discovery * document resolves no caller). + * + * [#23050 slice 8] …and `registerEmailEndpoints`' body to `email-routes.ts`, + * with its one. */ const SITE_FILES = REST_SERVER_REGISTRAR_FILES; const SOURCES = new Map(SITE_FILES.map((file) => [ @@ -116,12 +119,12 @@ const siteKey = (file: string, line: number) => `${file}:${line}`; * `registerApprovalsEndpoints`, `registerSharingEndpoints`, * `registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`, * `registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, - * `registerOpenApiEndpoints`) 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`) 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. @@ -654,9 +657,18 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { // (8 - 1) + 3 - 1 — the third module carrying two registrars counted in // `EXTRA_MOVED_REGISTRARS`, and the discovery registrar, which reads no // `resolveExecCtx`, in `MOVED_REGISTRARS_READING_NO_EXECCTX`. - expect(SITE_FILES).toHaveLength(8); + // + // [#23050 slice 8] 62 sites UNCHANGED / 113 → 117 mentions — the same MOVE. + // `registerEmailEndpoints`' body left for `email-routes.ts` with its one + // BARE site: 24 + 9 + 5 + 7 + 8 + 1 + 5 + 2 + 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 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); expect(SITES.length).toBe(62); - expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(113); + expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(117); 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 91382350b93..6adbc1f22b5 100644 --- a/packages/rest/src/meta-draft-read-door-census.test.ts +++ b/packages/rest/src/meta-draft-read-door-census.test.ts @@ -250,7 +250,9 @@ describe('[#20338] census: every draft switch in rest-server.ts is ledgered', () // (`security-routes.ts`) read no draft: 8 = 6 + 0 + 0 + 0 + 0 + 2 + 0, // 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. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(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); expect(SOURCE_FILES.map((file) => file.fileName)).toEqual([...REST_SERVER_REGISTRAR_FILES]); expect(SITES).toHaveLength(8); }); diff --git a/packages/rest/src/objectql-slot-consumer-census.test.ts b/packages/rest/src/objectql-slot-consumer-census.test.ts index 2baeb0d7e00..572115f8a50 100644 --- a/packages/rest/src/objectql-slot-consumer-census.test.ts +++ b/packages/rest/src/objectql-slot-consumer-census.test.ts @@ -106,6 +106,15 @@ import { RestServer } from './rest-server.js'; const HERE = dirname(fileURLToPath(import.meta.url)); const SOURCE = readFileSync(resolve(HERE, 'rest-server.ts'), 'utf8'); const PLUGIN = readFileSync(resolve(HERE, 'rest-api-plugin.ts'), 'utf8'); +/** + * [#23050 slice 8] The SIBLING slot's one consumer, the email send door: + * `RestServer#registerEmailEndpoints`' body moved, unedited, to + * `email-routes.ts`. §1's controls read `emailServiceProvider` there — its + * known-present read, and the negative for the window predicate — because that + * is where its two reads and its one call now are. It holds no + * `objectQLProvider` site, so it is not on `SLOT_FILES`. + */ +const EMAIL_SOURCE = readFileSync(resolve(HERE, 'email-routes.ts'), 'utf8'); /** * [#23050 slice 2] The files this census reads. `RestServer#registerBatchEndpoints`' @@ -161,7 +170,7 @@ describe('[#18546] §1 the `objectQLProvider` consumer census is mechanical', () // sibling `emailServiceProvider` field is read too. If either came back // empty the scanner would be reporting silence, not absence. expect(invocationSites(SOURCE, 'objectQLProvider').length).toBeGreaterThan(0); - expect(readSites(SOURCE, 'emailServiceProvider').length).toBeGreaterThan(0); + expect(readSites(EMAIL_SOURCE, 'emailServiceProvider').length).toBeGreaterThan(0); // Negative: a field that does not exist must read ZERO. This is what // makes a future zero mean "gone" rather than "the regex stopped // matching" — the failure mode that produced the two-consumer sentence @@ -185,7 +194,6 @@ describe('[#18546] §1 the `objectQLProvider` consumer census is mechanical', () it('⭐ [#18559] ALL THREE consumers now reach the slot through `wiredEngineOrLoud` — none reads it directly', () => { const sites = censusInvocationSites('objectQLProvider'); - const lines = SOURCE.split('\n'); const viaSeam = sites.filter((s) => { // The helper is named on the line that OPENS the call, two lines up // from the invocation in all three spellings — read in the site's @@ -203,10 +211,12 @@ describe('[#18546] §1 the `objectQLProvider` consumer census is mechanical', () // WINDOW of source text, so "3 of 3" is only a reading if the same // predicate can also say something else. `emailServiceProvider` is the // sibling slot whose one call site #15405 routed through the SWALLOWING - // helper instead, so it is the natural negative: + // helper instead, so it is the natural negative. [#23050 slice 8] Read + // in that slot's consumer's own file, `email-routes.ts` (`EMAIL_SOURCE`). + const emailLines = EMAIL_SOURCE.split('\n'); const wrappedIn = (field: string, helper: string) => - invocationSites(SOURCE, field).filter((s) => - lines.slice(Math.max(0, s.line - 4), s.line).join('\n').includes(helper)); + invocationSites(EMAIL_SOURCE, field).filter((s) => + emailLines.slice(Math.max(0, s.line - 4), s.line).join('\n').includes(helper)); // - the predicate must NOT claim that slot for `wiredEngineOrLoud`… expect(wrappedIn('emailServiceProvider', 'wiredEngineOrLoud(')).toHaveLength(0); // - …and it must still FIRE for the helper that slot really uses, so 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 89ae597a490..2f83e75662e 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -145,6 +145,10 @@ interface CensusEntry { * OpenAPI bodies (`discovery-routes.ts`) build no `query:` literal either — * they read the protocol's discovery document and its `getMetaItems` listings * by `type`, never through a `query:` slot. + * + * [#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. */ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly> = { 'rest-server.ts': 1, @@ -155,6 +159,7 @@ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly { // [#23050 slice 5] Length 5 → 6, the sum unchanged. // [#23050 slice 6] Length 6 → 7, the sum unchanged. // [#23050 slice 7] Length 7 → 8, the sum unchanged. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(8); + // [#23050 slice 8] Length 8 → 9, the sum unchanged. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(9); 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 fa6e8df786b..45910a472d7 100644 --- a/packages/rest/src/rest-server-docblock-position.test.ts +++ b/packages/rest/src/rest-server-docblock-position.test.ts @@ -165,6 +165,11 @@ describe('rest-server.ts docblock position', () => { * host interfaces' blocks and two functions' (it holds two registrars' bodies). * Neither the discovery body nor the OpenAPI body held a TSDoc block of its * own, so `rest-server.ts` keeps every block it had (164). + * + * [#23050 slice 8] `email-routes.ts` carries three, as the batch, approval and + * 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. */ const MOVED_FILE_FLOORS: Readonly, number>> = { 'data-action-routes.ts': 4, @@ -174,6 +179,7 @@ const MOVED_FILE_FLOORS: Readonly = REST_SERVER_REGISTRAR_FILES .filter((file): file is Exclude => file !== FILE) @@ -205,9 +211,10 @@ describe('[#23050 slice 3] the registrar list this pin reads', () => { // [#23050 slice 5] 4 + 3 + 3 + 8 + 3 = 21 over five, length 5 → 6. // [#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. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(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); 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(34); + expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(37); }); }); 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 250ca27ede9..bbf2d32f76b 100644 --- a/packages/rest/src/rest-server-query-number-census.test.ts +++ b/packages/rest/src/rest-server-query-number-census.test.ts @@ -324,8 +324,10 @@ describe('#20139 census — every numeric coercion in rest-server.ts is classifi // [#23050 slice 6] Nor do the two security bodies (`security-routes.ts`): // 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0, length 6 → 7. [#23050 slice 7] Nor do // the discovery and OpenAPI bodies (`discovery-routes.ts`): - // 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0 + 0, length 7 → 8. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(8); + // 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); expect(SOURCES.map(([file]) => file)).toEqual([...REST_SERVER_REGISTRAR_FILES]); expect(sites).toHaveLength(9); }); diff --git a/packages/rest/src/ui-view-route-identity.measurement.test.ts b/packages/rest/src/ui-view-route-identity.measurement.test.ts index d8e2dfe0f6b..3f7810231b3 100644 --- a/packages/rest/src/ui-view-route-identity.measurement.test.ts +++ b/packages/rest/src/ui-view-route-identity.measurement.test.ts @@ -351,7 +351,9 @@ describe('[#13214] §1 the REST seam — absent context vs entitled context', () // [#23050 slice 7] 7 → 8 (`discovery-routes.ts`): 56 and 71, +1 each // for the OpenAPI host literal's forwarder (the discovery body reads // neither term, so its literal forwards neither). - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(8); + // [#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); 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 21aaa839b9f..ccc34b84b52 100644 --- a/packages/rest/src/ui-view-route-tenancy.measurement.test.ts +++ b/packages/rest/src/ui-view-route-tenancy.measurement.test.ts @@ -1120,7 +1120,9 @@ describe('[#13214] §4 preconditions', () => { // for the OpenAPI host literal's forwarder (the discovery body reads // neither term, so its literal forwards neither); the ownership guard // still 1. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(8); + // [#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); 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 ea415567222..dab834f90fb 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -364,7 +364,13 @@ const MODULES = { // `{ error: { code, message } }`), so this count does not move and that // module declares 0 below; the sum is unchanged // (17 + 13 + 8 + 1 + 0 + 0 + 0 + 0 = 39). - stringError: 17, + // + // 17 → 16 (#23050 slice 8): NOT progress — the same MOVE as slices 1–3. + // The email send body's one literal string `error` body (the non-object + // 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). + stringError: 16, // 77 → 75 (#7981): registerSecurityEndpoints' two `handleError` arms moved // off the `{ code, error }` sibling-code literal onto the shared // `respondError` helper, banking that progress per the ratchet's own rule. @@ -438,7 +444,13 @@ const MODULES = { // for `discovery-routes.ts` carrying NO `{ code, error }` body, so this // count does not move and that module declares 0 below; the sum is // unchanged (17 + 15 + 8 + 9 + 4 + 1 + 0 + 0 = 54). - siblingCode: 17, + // + // 17 → 14 (#23050 slice 8): NOT progress — the same MOVE as `stringError` + // above. The email send body's three `{ code, error }` bodies (the 400s + // `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). + siblingCode: 14, }, // [#8850] The ADR-0112 error/fault-classification prologue, extracted from @@ -603,6 +615,23 @@ const MODULES = { stringError: 0, siblingCode: 0, }, + + // [#23050 slice 8] `RestServer#registerEmailEndpoints`' body — the + // transactional send door, `POST /email/send` — 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 17 → 16, siblingCode 17 → 14), + // 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/email-routes.ts': { + dialectOnly: + 'moved out of `rest-server.ts` by #23050 slice 8 and held to exactly the guarantee it had there — the two dialect facts, never a write-site count', + ratchet: '#9559 (option 1: convert onto the shared sendOk/sendError)', + stringError: 1, + siblingCode: 3, + }, }; /** Identifiers whose `.json()` READS a request rather than writing a response. */