Skip to content

Commit d5666be

Browse files
refactor(rest): move registerEmailEndpoints into email-routes.ts (#23050 slice 8) (#23882)
Part of #23050 Clause-②: no Slice 8 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–7's shape (PR #23670, PR #23705, PR #23719, PR #23729, PR #23763): the body of `RestServer#registerEmailEndpoints` (`POST {basePath}/email/send`, the transactional send door onto the `IEmailService` provider) moves to ONE new module, `packages/rest/src/email-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 the claim names up front (seat ruling `6205442867`) is not used, and no receiver token was rewritten. `rest-server.ts` goes from 10,127 to 10,047 lines (base `f7e836da1e`). The card stays open for the later slices. ## The pattern, applied **Members the moved body reads**, from an AST walk of the method (TypeScript compiler API, symbol-resolved, base `f7e836da1e`): `registerEmailEndpoints` (JSDoc `:9063`, method `:9082`-`:9177`, body `:9083`-`:9176`, 94 lines). | kind | member | reads | class declaration | |---|---|---|---| | field | `routeManager` | 1, the `register` call | `:2022`, private | | field | `emailServiceProvider` | 2, both inside the handler (the wiring guard and the provider call) | `:2091`, private, not `readonly` | | method | `resolveExecCtx` | 1 | `:2868`, private async | | method | `enforceAuth` | 1 | `:2468`, private | No bare `this`, no element access on `this`, no `this`-rebinding `function`, and no `RestServer.NAME` access (static reads: none). Module-level reads: - `logError` (`./log.js`): 6 more uses in `rest-server.ts`, so it stays there; the module imports it from the same module. - `seamOrUndefined`: declared in `rest-server.ts` (`:1493`), 5 more uses there. It is `export`ed from `rest-server.ts` and imported by the module: one declaration, never a copy. `src/index.ts` does not re-export it, so the `.d.ts` does not move (measured below). The same class of edit as slice 5's `export function mayReadPendingDrafts`. **Mechanism hypotheses from the dispatch, measured:** - (1) **Partly falsified.** The body reads `routeManager`, `emailServiceProvider`, `resolveExecCtx` and `enforceAuth`, and nothing else. `tenancyServiceProvider`, `kernelManager`, `resolveRequestEnvironmentId`, `resolveAnonymousFormTenancy` and `readFormObjectDefinitions` are NOT read by it: they belong to the next method, `resolveAnonymousFormTenancy` (`:9185`, a forms helper; the same walk on it reads `kernelManager`, `resolveRequestEnvironmentId` and `tenancyServiceProvider`). The claim's "about 210 lines" ran past the method's end; the method is 96 lines (plus its 19-line JSDoc), and nothing past `:9177` moved. - (2) **Confirmed.** No `RestServer.NAME(` call in the body. - (3) **Measured;** the readers are listed below. **Host shape (slice 1's shape B):** `EmailRouteHost` (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 `registerEmailEndpoints.call(host, basePath)`. Methods go as late-bound closures, `(...a) => this.m(...a)`. The call site in `registerRoutes`' `registerForBase` (`this.registerEmailEndpoints(bp);`, `:4806` at base, unconditional per base) 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 (`:5175` / `:5179` at base) wraps `registerMetadataEndpointsInner`, not this registrar | value | | `emailServiceProvider` | **request time**: both reads are inside the handler | not by the class (constructor only, `:2173`), but not `readonly` | **getter** | The getter is measured, not argued. A one-off vitest file (copied in, run, deleted) boots a source `RestServer` with no email provider, drives `POST /api/v1/email/send`, assigns a provider on the instance AFTER `registerRoutes()`, and drives it again: - this head: `501` then `200` (`MEASURE provider-after-registration: 501 -> 200`), the late provider's `send` called once. A second case sets `resolveExecCtx` and a spy on `enforceAuth` AFTER `registerRoutes()`: both are the ones the door calls (`sentBy: 'late-user'` stamped). `Tests 3 passed (3)`. - by-value ablation through `scripts/ablation-replace.mjs`: the literal's `get emailServiceProvider() { return readEmailServiceProvider(); },` replaced by `emailServiceProvider: this.emailServiceProvider,` (anchor 1 → 0, replacement 0 → 1, blob `2ba4159180` → `aad755d1ef`). The door freezes at registration: `expected … [ 200 ]`, received `501`. `Tests 1 failed | 2 passed (3)`. Restored by the tool's trap: blob `2ba4159180` equals `HEAD`, `git diff HEAD` empty. Source path (relative import), so no `dist` leg applies. - the same file against the BASE source (base `rest-server.ts` restored into the tree with a trap, then `git checkout HEAD` and blob/diff proof): `501 -> 200`, `Tests 3 passed (3)`, the same answers as the head. ## Proof of move-only - **`.d.ts`:** `packages/rest/dist/index.d.ts` and `index.d.cts` built at base `f7e836da1e` (this worktree, before any edit), at the move commit `679fe5b05` and at the merge head `35f207b70` (and again after the whole-workspace build) are byte-identical: sha256 `7489de84bc6feccb7cb1f28bcbe4a97775324959c61c9bfc35e8a8c45a6f3bc9` for all files, `cmp` silent. The head bundles carry a head-only marker (`readEmailServiceProvider`: 2 hits in each of `index.js` and `index.cjs`, 0 at base), so the declaration was rebuilt from the moved source, not replayed. `EmailRouteHost`, `email-routes` and `seamOrUndefined` appear 0 times in the `.d.ts`; `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 email or `seamOrUndefined` 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`; and both off under scoped-optional. Each dump holds `getRoutes()` (functions as arity), the ordered server registration calls, and a whitespace-free sha256 of every handler's source plus the source itself. - **28 of 28 dump files byte-identical**, base vs move commit and base vs merge head: routes, calls, handler digests and handler sources in every config. default 75 routes (1 email 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). A second base run was identical to the first. - Because no token changed, every handler digest is identical, the email door's included (9 email handlers over the seven configs), with no mapping. - **The handler digest can fail:** a positive control changed one character of the moved handler (`'[REST] Email send unhandled error:'` → `…error!'`, through `ablation-replace.mjs`; anchor 1 → 0, blob `30a8f5a0c8` → `78879bb810`). Routes and calls were unchanged in all seven configs, and the handler digests differed in exactly the email door of each mounted base (`POST /api/v1/email/send`, and `POST /api/v1/environments/:environmentId/email/send` where scoped), nothing else. Restored, blob `30a8f5a0c8` equals `HEAD`. - **Moved body:** base `rest-server.ts` lines 9083-9176, one 4-space indent removed, against `email-routes.ts` lines 70-163: `diff` exits 0 (94 lines). The body has no whitespace-only line, no trailing whitespace and no template literal spanning lines. - **Moved-line accounting:** `git diff --color-moved=plain --color-moved-ws=allow-indentation-change` base → `679fe5b05` over the two files: all 94 deleted body lines are detected as moved; the one other deleted line is `seamOrUndefined`'s declaration gaining `export`. `rest-server.ts` is +15 / −95 there: the stub body (host literal plus `.call`, +12 / −94), the module's import and its comment (+2), and the `export` (+1 / −1). ## Readers re-pointed (sums unchanged, no gate loosened) `packages/rest/src/rest-server-registrar-files.ts` gains `email-routes.ts` with a `[#23050 slice 8]` comment (length 8 → 9). 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 + 0 + 0 + 0 + 0 + 0 + 0 | length 8 → 9 | | `rest-server-canonical-query-ast.test.ts` (`repo` project) | 4 = 1 + 3 + 0 + 0 + 0 + 0 + 0 + 0 + 0 `query:` slots | floor `email-routes.ts: 0`, length 8 → 9 | | `meta-draft-read-door-census.test.ts` | 8 = 6 + 0 + 0 + 0 + 0 + 2 + 0 + 0 + 0 | the email body reads no draft; length 8 → 9 | | `rest-server-docblock-position.test.ts` | floors 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 = 37 | floor `email-routes.ts: 3` (header, host interface, function; the body held no TSDoc block); `rest-server.ts` keeps 164 blocks, floor 100; length 8 → 9 | | `execctx-consumer-census.test.ts` | 62 sites = 24 + 9 + 5 + 7 + 8 + 1 + 5 + 2 + 1 | the email site is BARE and guarded on its next line, as before (46 bare / 16 caught unchanged). Mentions 113 → 117: the seam once (forwarder ×2 in `rest-server.ts`, interface member ×2 in the module); `rest-server.ts` 57 → 58 (one site out, two forwarder mentions in). Forwarders 10 = (9 − 1) + 3 − 1; neither map gains an entry, because the module carries one registrar and it reads the seam | | `ui-view-route-identity.measurement.test.ts` / `ui-view-route-tenancy.measurement.test.ts` | `this.enforceAuth(` 57, `this.resolveExecCtx(` 72 over the list (56 / 71 before, +1 each for the email literal's forwarders); ownership guard 1 | length 8 → 9 | | dogfood `authz-conformance.test.ts` (realtime tripwires) | 0 hits over the nine files | length 8 → 9 | | dogfood `authz-probe-blind-spot.census.ts`, `rest-server.ts` row | 70 / 19 / 51 unchanged; `this.routeManager.register(` 63 = 22 + 9 + 5 + 7 + 8 + 1 + 6 + 4 + 1; `private register*Endpoints(` 16 | new control `email-routes.ts: this.routeManager.register(` 1; `registrar list length` 9; `enforceAuth` 96 → 100 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 | Readers that read `rest-server.ts` by path outside the list: - `objectql-slot-consumer-census.test.ts`: its §1 controls use `emailServiceProvider`, the sibling slot, as their known-present read and as the negative for the window predicate. Those reads moved: `readSites` over base `rest-server.ts` found `:9093`, `:9122` (invocation `:9122`, wrapped by `seamOrUndefined(`); at the move commit `rest-server.ts` holds only the host literal's getter line, so `wrappedIn('emailServiceProvider', 'seamOrUndefined(')` read 0 and the test went red. The controls now read `email-routes.ts` (`EMAIL_SOURCE`): reads `:80`, `:109`, invocation `:109`; `seamOrUndefined(` 1, `wiredEngineOrLoud(` 0, as before. The `objectQLProvider` census itself (3 consumers over `SLOT_FILES`) is unchanged, and `SLOT_FILES` is not widened (the module holds no `objectQLProvider` site). - `scripts/check-route-envelope.mjs` (a gate's target paths, `domain:spec`): `email-routes.ts` is discovered by the `*-routes.ts` convention and declared `dialectOnly` with ratchet #9559, as the seven earlier modules are, at its measured `stringError 1` (the non-object body's 400 `INVALID_REQUEST`, `'JSON body required'`) and `siblingCode 3` (`INVALID_REQUEST`, `VALIDATION_FAILED`, `EMAIL_SEND_FAILED`); the two 501 `{ code, message }` bodies have no `error` key and are counted by neither. `rest-server.ts` goes 17 → 16 and 17 → 14, so the sums 16 + 13 + 8 + 1 + 0 + 0 + 0 + 0 + 1 = 39 and 14 + 15 + 8 + 9 + 4 + 1 + 0 + 0 + 3 = 54 are unchanged (the gate compares both counts exactly, in both directions). Output differs from base only in those two rows (18 → 19 modules, 9 → 10 ratcheted). `--self-test` green. Each history entry says "NOT progress — the same MOVE", as slices 1–3 wrote. - `docs/qa/platform-checklist/areas/integration-system.json` (`domain:devx`): the email item's source entry `packages/rest/src/rest-server.ts#registerEmailEndpoints (… enforceAuth 401, non-object→400 INVALID_REQUEST, VALIDATION_FAILED passthrough, 501 no-provider, 500 EMAIL_SEND_FAILED)` lists the body's answers, so it now names `packages/rest/src/email-routes.ts#registerEmailEndpoints`; the item's `verify` prose said "(rest-server.ts registerEmailEndpoints: …)" for the same answers and now says `email-routes.ts`. `check:platform-checklist`: symbol anchors 735/745 → 735/745 resolved (the re-pointed anchor resolves), cited sources 347 → 348. - No new ADR anchor: the body cites no `ADR-NNNN Dk`, and `check-adr-anchors` reads 1764 decision-letter citations at base and at head. **Red before the re-points**, each measured with the move in place (`679fe5b05`): the seven `local` readers read `Test Files 7 failed (7)`, `Tests 8 failed | 121 passed (129)` (six length pins, the docblock pin's unfloored new module, and the `objectQLProvider` census's `seamOrUndefined(` control at 0). `rest-server-canonical-query-ast.test.ts` (`repo`) read `Tests 2 failed | 61 passed | 9 skipped (72)`. The dogfood census read `control "registrar list length" moved … expected 9 to be 8`; `authz-conformance.test.ts` could not load at that point (`@objectstack/verify` was not built yet), so its length pin's red was not observed, only its green after the re-point. `tsc --noEmit` was clean, but `check:test-typecheck` found 2 type errors, the two typed floor tables without the new module. `check:route-envelope` read `email-routes.ts NOT DECLARED` (MODULES and EXPRESS_RESPONSE_MODULES) and `rest-server.ts` 16 vs 17 / 14 vs 17. ## Readers examined and not re-pointed - **`packages/spec` (stopped at, per the dispatch, not edited):** `packages/spec/src/api/discovery.zod.ts` (about `:396`) says "the REST server's `registerEmailEndpoints` mounts under `getApiBasePath()`". It names the method, not a file, and the method still registers the door (through the module), so it stays true. No `packages/spec/liveness` row cites the email registrar or a moved line, and `check:liveness` output is identical at base and head. - `rest-server.ts`' `getMountedEmailRouteBase` docblock names `registerEmailEndpoints` as the recorder of the send route. It ships in the published `.d.ts` (a public method), and it stays true. Not edited. - Prose that names the method or the door without a file, still true: `packages/rest/src/discovery-routes.ts` (about `:304`), `discovery-advertised-direct-mounts.parity.test.ts`, `packages/metadata-protocol/src/discovery-schema-conformance.test.ts`, `rest.test.ts`'s email describe block, `rest-api-plugin.ts`'s provider comment. - `meta-state-route-engine-outage.test.ts` §5 says "`POST /email/send` carried the other `.catch(() => undefined)` in this file": dated repair history about `rest-server.ts`; the test drives the door over HTTP and reads no source. Not edited. - `packages/rest/CHANGELOG.md` (released text) and `docs/audits/2026-07-dispatcher-client-route-coverage.md` (a dated audit naming `POST /email/send`, no file or line): not edited. - No `rest-server.ts:NNNN` line citation in the tree falls inside the moved range (`:9063`-`:9177`); the highest cited line is `:5872`. As with every earlier slice's import, every line after the new import (`:342`) moved down by 2 (and every line after the stub by 2 − 80); none of those citations is in a gated document (`check:platform-checklist` reports 0 surviving line citations; `check:scripts-symbol-anchors` 0 line anchors on tracked targets). - `REST_ROUTE_LEDGER` names `POST /api/v1/email/send` by route, not by file: unchanged. ## Acceptance notes - **The provider's call receiver, measured.** The handler calls `this.emailServiceProvider!(environmentId)`; through the host that call's receiver is the host literal, where at base it was the `RestServer` (the one-off file's third case records `receiver === server`: `true` at base, `false` at head). Only a provider written as a non-arrow `function` that reads `this` could observe it. The shipped provider (`rest-api-plugin.ts`, about `:406`) is an `async` arrow, and the constructor parameter's type declares no `this`. Every earlier slice that moved a provider call through a getter (`objectQLProvider`, `approvalsServiceProvider`, the two sharing providers, `analyticsServiceProvider`, `securityServiceProvider`) has the same property. Recorded, not changed: a bound hand-over would be a new host shape, which this slice may not introduce. - The moved `[#15405]` comment's references ("the `if (!this.emailServiceProvider)` guard above", "`rest-api-plugin.ts` hands over") still read true in the module. ## Gates and tests Measured at this branch: base `f7e836da1e`, the move `679fe5b05`, the re-points `626eb4c15`, and one merge of `origin/main` `6422da58e8` (`35f207b70`: one commit, #23854, in `packages/spec` contracts and `service-automation`; 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: `35f207b70`.** - `pnpm install --frozen-lockfile` exit 0; `pnpm --filter '@objectstack/rest...' --filter '@objectstack/verify...' build` exit 0; then `pnpm turbo run build --concurrency=2`, the whole workspace, exit 0 (76 tasks, 74 cached), 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 5892 passed / 326 skipped (6218). - `pnpm --filter @objectstack/rest exec vitest run --project repo --maxWorkers=2`: Test Files 5 passed (5), Tests 242 passed / 9 skipped (251). - `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 `35f207b70` (stderr names that commit; the `--repo` assertion holds; 15 paths against merge base `6422da58e`): 80 commands, all run at `35f207b70`. 79 exited 0 on the first pass; `pnpm check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: no `dist` for 18 packages) and exited 0 after the whole-workspace build above. `--ran`: `Run reconciliation — 80 derived, 80 run, 0 NOT-MEASURED, 0 UNRUN.` Among them: `pnpm check:route-envelope`, `pnpm check:platform-checklist`, `pnpm --filter @objectstack/spec run check:liveness`, `pnpm check:durability-log-level`, `pnpm check:dispatcher-error-vocabulary`, `pnpm check:dts-closure`, `pnpm check:published-files`, `pnpm check:cross-package-test-inputs`, `pnpm check:nul-bytes` and `pnpm check:pm-dispatch-gates`. `check-adr-anchors` (not derived) also run: exit 0. - Scan outputs, base `f7e836da1e` vs head `35f207b70`, diffed: identical for `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-symbol-anchors` and `check:liveness`. Differing only by this diff's new file: `check-meta-type-normalized` (36 → 37 files), `check-affected-docs` (route sources 26 → 27, registration call sites 21 → 22, by the `*-routes.ts` convention, no edit), `check-dispatcher-error-vocabulary` and `check-error-status-conformance` (+1 scanned file each; 57 unregistered sites and 465 producer sites unchanged), `check-undeclared-dep-imports` (+1 file), `check-route-envelope` (the two rows above), `check-scripts-symbol-anchors` (+4 file-level anchors, the gate's new comments) and `check:platform-checklist` (348 cited sources). `check-adr-anchors` reads 1764 decision-letter citations at both; its +2 citations / +1 file and `check-undeclared-dep-imports`' +1 specifier are `main`'s (#23854's changeset and `engine.ts` import). - Lint: `pnpm exec eslint --no-inline-config --format json` over the 14 changed code files: 0 errors, 0 warnings (the 15th changed file is the checklist JSON, which `eslint .` never reads). 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 the `rest-server.ts` row's two counts, sums unchanged) and `docs/qa/platform-checklist/areas/integration-system.json` (`domain:devx`, one source anchor and one `verify` phrase). The dogfood files (`packages/qa/dogfood/test/authz-conformance.test.ts`, `authz-probe-blind-spot.census.ts`) are re-pointed as in slices 3–7. `check-governed-merges --test` over the 15 paths: NOT governed; 426 changed lines. --- _Generated by [Claude Code](https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2034975 commit d5666be

15 files changed

Lines changed: 299 additions & 127 deletions

‎docs/qa/platform-checklist/areas/integration-system.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -856,7 +856,7 @@
856856
{
857857
"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",
858858
"oracle": "api",
859-
"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",
859+
"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",
860860
"evidence": "the three responses + the dev-transport landing (plus the wrong-vocabulary refusal, if the probe was driven)"
861861
}
862862
],
@@ -877,7 +877,7 @@
877877
"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)",
878878
"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)",
879879
"examples/app-showcase/src/system/emails/index.ts#showcase_task_done_email (showcase_task_done_email fixture)",
880-
"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)",
880+
"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)",
881881
"packages/rest/src/rest-route-ledger.ts (email family — POST /api/v1/email/send → client email.send)",
882882
"packages/spec/src/api/error-code-ledger.zod.ts#EMAIL_SEND_FAILED (EMAIL_SEND_FAILED under @objectstack/rest)",
883883
"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)"

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1629,7 +1629,8 @@ describe('#9084 — the rest-server.ts transport tripwire keys off mechanics', (
16291629
// [#23050 slice 5] Length 5 → 6 (`analytics-routes.ts`), still 0 over it.
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.
1632-
expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(8);
1632+
// [#23050 slice 8] Length 8 → 9 (`email-routes.ts`), still 0 over it.
1633+
expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(9);
16331634
expect(REST_SERVER_REGISTRAR_PATHS[0]).toBe(REST_SERVER);
16341635
const listed = REST_SERVER_REGISTRAR_PATHS.map((file) => readFileSync(join(REPO_ROOT, file), 'utf8'));
16351636
for (const probe of [MECHANICS, ROUTES]) {

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

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -520,11 +520,11 @@ 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` and `discovery-routes.ts`, the modules the bodies of ' +
524-
'`registerDataActionEndpoints`, `registerBatchEndpoints`, `registerApprovalsEndpoints`, ' +
523+
'`analytics-routes.ts`, `security-routes.ts`, `discovery-routes.ts` and `email-routes.ts`, the modules ' +
524+
'the bodies of `registerDataActionEndpoints`, `registerBatchEndpoints`, `registerApprovalsEndpoints`, ' +
525525
'`registerSharingEndpoints`, `registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`, ' +
526-
'`registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, `registerDiscoveryEndpoints` and ' +
527-
'`registerOpenApiEndpoints` moved to; ' +
526+
'`registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, `registerDiscoveryEndpoints`, ' +
527+
'`registerOpenApiEndpoints` and `registerEmailEndpoints` moved to; ' +
528528
'reachable = those inside registerMetadataEndpoints',
529529
// [#15542 / #15854] ⭐ THE POPULATION RULE LEARNED A SECOND SPELLING, and
530530
// the numbers it produces did NOT move: 80 / 19 / 61, exactly as before.
@@ -733,25 +733,38 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
733733
// the OpenAPI body reads `enforceAuth` (its host literal's forwarder, two;
734734
// its host interface's member, two) — the discovery document is served to
735735
// anyone, so its literal forwards no `enforceAuth`. No call site was added.
736+
// [#23050 slice 8] 70 / 19 / 51 UNCHANGED, the same measurement again:
737+
// `registerEmailEndpoints`' body (one registration site, `POST
738+
// {base}/email/send`, outside `registerMetadataEndpoints`, so in the blind
739+
// spot) moved to `email-routes.ts`, which joins the list (length 8 -> 9)
740+
// with its own control. `this.routeManager.register(` stays 63 over the
741+
// nine files (22 here + 9 + 5 + 7 + 8 + 1 + 6 + 4 + 1),
742+
// `private register*Endpoints(` stays 16 (the registrar is still declared
743+
// here), and `enforceAuth` 96 -> 100 is RE-ANCHORED, not relaxed: the one
744+
// call site moved with its route and is counted in both readings; the four
745+
// new occurrences are this slice's seam, the same two spellings once (the
746+
// host literal's forwarder, two; the host interface's member, two). No call
747+
// site was added.
736748
controls: {
737749
'private register*Endpoints(': 16,
738750
'this.routeManager.register(': 63,
739-
'registrar list length': 8,
751+
'registrar list length': 9,
740752
'data-action-routes.ts: this.routeManager.register(': 9,
741753
'batch-routes.ts: this.routeManager.register(': 5,
742754
'approvals-routes.ts: this.routeManager.register(': 7,
743755
'sharing-routes.ts: this.routeManager.register(': 8,
744756
'analytics-routes.ts: this.routeManager.register(': 1,
745757
'security-routes.ts: this.routeManager.register(': 6,
746758
'discovery-routes.ts: this.routeManager.register(': 4,
759+
'email-routes.ts: this.routeManager.register(': 1,
747760
// Both halves of the new rule carry their own control, so neither can go
748761
// silently to zero: a helper deleted and its routes inlined back would
749762
// still read population 80, and only these two controls would notice the
750763
// shape moved and force this provenance to be re-read.
751764
'registerPerItemRoute(': 8,
752765
'const registerPerItemRoute =': 1,
753766
'forwarder slice: this.routeManager.register(': 1,
754-
enforceAuth: 96,
767+
enforceAuth: 100,
755768
},
756769
note:
757770
'The single non-tripwire probe names ONE registrar of 16. The other 15 can never mint a key: ' +

‎packages/rest/src/email-routes.ts‎

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23050 slice 8] The email route of `RestServer` —
5+
* `POST {basePath}/email/send`, the transactional send door onto the
6+
* `IEmailService` provider — moved out of `rest-server.ts` whole. The
7+
* registrar runs once per mounted base, unconditionally, so an
8+
* environment-scoped deployment mounts the door twice.
9+
*
10+
* A MOVE, not a redesign. The function below is the body of
11+
* `RestServer#registerEmailEndpoints` as it stood, re-indented one level and
12+
* otherwise unedited: the same route, registered at the same position, by the
13+
* same handler, answering the same bodies. It calls no class `static`, so no
14+
* token of it changed.
15+
*
16+
* ## How the moved body reaches the class
17+
*
18+
* The shape slice 1 set (`data-action-routes.ts`' header carries the full
19+
* reasoning), with slice 2's refinement. The class keeps the private method —
20+
* the published `dist/*.d.ts` lists its name, docblock and position — and the
21+
* body is one host literal ({@link EmailRouteHost}) plus
22+
* `.call(host, basePath)`. A method is handed over as a closure that calls it
23+
* on the server at call time, so a test double or spy set on the server is
24+
* still the one the body reaches.
25+
*
26+
* Each field was checked on its own, not inherited from an earlier slice:
27+
*
28+
* - `routeManager` goes BY VALUE. The body reads it only for its one
29+
* `register` call, which runs while registration runs, and the class's one
30+
* `routeManager` reassignment wraps `registerMetadataEndpointsInner`, never
31+
* this registrar.
32+
* - `emailServiceProvider` goes as a GETTER. Both of the body's reads of it
33+
* (the wiring guard and the provider call) are inside the handler, so they
34+
* happen at REQUEST time, and the field is not `readonly`, so a value copied
35+
* at registration could part from it. The getter reads the field the handler
36+
* read, when it read it.
37+
*
38+
* Module-level helpers stay where they were. `logError` is imported from the
39+
* module `rest-server.ts` imports it from. The one helper declared in
40+
* `rest-server.ts` itself, `seamOrUndefined` (the provider-seam normalisation
41+
* its docblock there explains), is exported from there and imported here: one
42+
* declaration, never a copy. The package entry does not re-export it, so the
43+
* published surface does not move.
44+
*/
45+
46+
import { logError } from './log.js';
47+
import { seamOrUndefined, type RestServer } from './rest-server.js';
48+
49+
/**
50+
* The `RestServer` members {@link registerEmailEndpoints} reads — every one of
51+
* them, and no other. Each is typed AS the class member (an indexed access on
52+
* the class), so a change to a member's signature on the class is a change
53+
* here too, and naming a member the class does not have is a compile error.
54+
* See the module header for why `emailServiceProvider` is a getter.
55+
*/
56+
export interface EmailRouteHost {
57+
readonly routeManager: RestServer['routeManager'];
58+
readonly emailServiceProvider: RestServer['emailServiceProvider'];
59+
resolveExecCtx: RestServer['resolveExecCtx'];
60+
enforceAuth: RestServer['enforceAuth'];
61+
}
62+
63+
/**
64+
* Register the transactional email send endpoint —
65+
* `RestServer#registerEmailEndpoints`' body, called by that method with the
66+
* server as `this`. The method's own docblock, which the published declaration
67+
* carries, names the route and its request body.
68+
*/
69+
export function registerEmailEndpoints(this: EmailRouteHost, basePath: string): void {
70+
const isScoped = basePath.includes('/environments/:environmentId');
71+
this.routeManager.register({
72+
method: 'POST',
73+
path: `${basePath}/email/send`,
74+
handler: async (req: any, res: any) => {
75+
try {
76+
const environmentId = isScoped ? req.params?.environmentId : undefined;
77+
const context = await this.resolveExecCtx(environmentId, req);
78+
if (this.enforceAuth(req, res, context)) return;
79+
80+
if (!this.emailServiceProvider) {
81+
res.status(501).json({
82+
code: 'NOT_IMPLEMENTED',
83+
message: 'Email service is not configured on this deployment',
84+
});
85+
return;
86+
}
87+
// [#15405] `seamOrUndefined`, not the retired
88+
// `.catch(() => undefined)`. That handler attaches to the
89+
// promise the call RETURNS, so it can only ever see a
90+
// REJECTION: a host wiring a non-`async` provider — which
91+
// the seam's declared type cannot prevent, and
92+
// `RestServer`'s constructor is the public wiring point —
93+
// throws while the expression is still being evaluated, so
94+
// there is no promise to attach to and the handler is never
95+
// reached (commit add6a1b1c).
96+
//
97+
// ⚠️ NOT reachable from the SHIPPED wiring: the provider
98+
// `rest-api-plugin.ts` hands over is declared `async`.
99+
// Repaired because it is the same retired spelling at an
100+
// embedder-reachable seam, ⛔ not on a claim of live impact.
101+
//
102+
// The ANSWER is deliberately unchanged — absorb to
103+
// `undefined` and take the 501 below. ⛔ Unlike the engine
104+
// seam, this one must NOT go loud: the
105+
// `if (!this.emailServiceProvider)` guard above has already
106+
// answered the wiring question, and "not configured" and
107+
// "configured and unusable" both mean this deployment cannot
108+
// send mail — one 501, no fact lost by folding them.
109+
const emailService = await seamOrUndefined(() => this.emailServiceProvider!(environmentId));
110+
if (!emailService || typeof emailService.send !== 'function') {
111+
res.status(501).json({
112+
code: 'NOT_IMPLEMENTED',
113+
message: 'Email service is not configured on this deployment',
114+
});
115+
return;
116+
}
117+
118+
const body = req.body ?? {};
119+
if (!body || typeof body !== 'object') {
120+
res.status(400).json({ code: 'INVALID_REQUEST', error: 'JSON body required' });
121+
return;
122+
}
123+
// Stamp sentBy from the authenticated context when caller didn't supply one.
124+
const input = {
125+
...body,
126+
...(body.sentBy === undefined && (context as any)?.userId
127+
? { sentBy: (context as any).userId }
128+
: {}),
129+
};
130+
131+
try {
132+
const result = await emailService.send(input);
133+
if (result?.status === 'sent') {
134+
res.status(200).json(result);
135+
} else {
136+
// failed / queued — still surface to client with 200 so clients can branch on status.
137+
res.status(200).json(result);
138+
}
139+
} catch (err: any) {
140+
// Validation errors from normalizeMessage are surfaced as 400.
141+
const message = String(err?.message ?? err ?? 'send failed');
142+
if (message.startsWith('VALIDATION_FAILED')) {
143+
res.status(400).json({
144+
code: 'VALIDATION_FAILED',
145+
error: message.replace(/^VALIDATION_FAILED:\s*/, ''),
146+
});
147+
return;
148+
}
149+
throw err;
150+
}
151+
} catch (error: any) {
152+
logError('[REST] Email send unhandled error:', error);
153+
res.status(500).json({
154+
code: 'EMAIL_SEND_FAILED',
155+
error: String(error?.message ?? error ?? 'send failed').slice(0, 500),
156+
});
157+
}
158+
},
159+
metadata: {
160+
summary: 'Send a transactional email via the configured EmailService',
161+
tags: ['email'],
162+
},
163+
});
164+
}

0 commit comments

Comments
 (0)