Skip to content

Commit 63b86b3

Browse files
fix(rest): the approvals, email, sharing, sharing-rule and analytics routes answer from the request's environment kernel when one REST plugin fronts many environments (#30013)
<!-- claude-projects-attribution: {"github_login":"os-zhuang"} --> _Requested by **Sam** · [project thread](https://claude.ai/code/project/chan_01GsBogozeZG5sdpAARrN7HP?thread=cmsg_01GsBogozeZG5sdpAARrN7HPSQS59N7Px4367s49jMWcuS)_ Part of #30011 This PR is the whole of that card. It says `Part of` rather than a closing keyword because the card carries no claim naming a `claude/issue-*` branch and this PR's head branch cannot take that shape, so `check:closing-target-claim` could never pass with a closing keyword. The seat closes the card when this lands. Refs #29999 (the defect record the card implements; it stays open for cloud's staging re-read). Clause-②: no Metadata-protocol: none _Keep one value on each of the two lines above, each line starting with its key. [Human Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum) item 5 says what they mean._ ## 维护者速读 - 改了什么:一个 REST 插件挂在宿主内核前面、后面有多个环境内核时(云上托管运行时),审批、邮件、共享、共享规则、分析这五类接口改为到请求所属环境的内核里取服务,不再到宿主内核里取。 - 为什么改:宿主内核上没有这些服务,所以每个托管环境的审批中心列表都是空的、单条审批打开是 501,记录页的审批区也是空的,共享、共享规则和邮件接口一律 501(#29999)。 - 风险与回滚:只影响注册了 kernel-manager 的部署;单内核(社区版自部署)走原路,有测试钉住。分析接口在环境缺分析服务时不再借用宿主的分析服务,改为返回 501。回滚即 revert 本 PR,无配置或数据迁移。 - 先看:`packages/rest/src/rest-api-plugin.ts`(`environmentScopedProvider` 一段,风险在“不回退到宿主”那条腿)、`packages/rest/src/analytics-routes.ts`(删掉宿主回退的 `resolveService`)、`packages/rest/src/rest-environment-scoped-providers.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/rest exec vitest run src/rest-environment-scoped-providers.test.ts`,预期 1 个文件、58 个测试全部通过。 - 需要你做的:无,席位落地。 ## What changes `packages/rest/src/rest-api-plugin.ts` builds the five provider seams it hands `RestServer` (`email`, `sharing`, `approvals`, `sharingRules`, `analytics`) from one factory, `environmentScopedProvider(slot)`. Each answers by these legs, in order: - **An environment id and a registered `kernel-manager`**: read the slot on that environment's kernel, acquired with `kernelManager.getOrCreate(id)`, the same acquisition the data path's `resolveProtocol` makes. "Never registered" (the branded registry error) is `undefined`, and any other fault (a factory that threw) is re-raised, matching `objectQLProvider`'s three-state classification. There is no fallback to the plugin's own (host) occupant. - **The reserved `platform` id, no id, or no `kernel-manager`**: `ctx.getService(slot)` on the plugin's own kernel, exactly as before. The provider resolves no environment itself. Each route asks the one existing resolver, `RestServer.resolveRequestEnvironmentId(environmentId, req)`, and hands its answer to the provider: - the scoped mount (`/environments/:environmentId/...`) answers the param; - the unscoped (hostname-routed) mount answers through the host's `kernel-resolver` (`resolveEnvironment` first, so the id lookup buys no kernel), then the hostname / `X-Environment-Id` chain, as the data routes do. Route-side, each moved route body's host interface gains `resolveRequestEnvironmentId` (typed as the class member), and `rest-server.ts` adds it to the host literals: - `approvals-routes.ts`: `resolveService(environmentId, req)` at all 8 call sites. - `sharing-routes.ts`: the same for the shares doors and the sharing-rule doors (8 call sites). - `email-routes.ts`: `POST /email/send`. - `record-page-routes.ts`: the approvals part of `POST /data/:object/:id/page`. - `analytics-routes.ts`: `resolveService` keeps the shared resolver call it already made, drops its own inline `kernelManager` lookup (now the provider's) and drops the host fallback. `kernelManager` leaves `AnalyticsRouteHost` and the analytics host literal. A changeset (`@objectstack/rest` patch) carries the same declaration lines. ## Why On cloud's hosted runtime one `RestApiPlugin` sits on the host kernel in front of many environment kernels, and each environment registers these five services on its own kernel. The providers read `ctx.getService` on the host kernel whatever id they were handed, so the environment's services were never reached (#29999). The data routes never had this problem because they resolve the request's kernel; this change makes the five seams use that same resolution. The reading per provider, as the card asked (step 2), measured by the hosted-shape test below against the base sources: | Provider | Doors | Hosted shape before this PR | |:--|:--|:--| | `approvals` | `GET /approvals/requests`, `GET /approvals/requests/:id` (and the lock, decide, recall and action doors on the same seam), record page approvals part | **Wrong.** Host-only read; the host carries no `approvals`, so the list answered `{ data: [] }`, a single request 501 and the record page part `{ requests: [], lock: null }`. | | `sharing` | `/data/:object/:id/shares` | **Wrong.** Host-only read; the host mounts no sharing plugin, so 501. | | `sharingRules` | `/sharing/rules/*` | **Wrong.** Same as `sharing` (the sharing plugin registers both slots on the environment kernel), so 501. | | `email` | `POST /email/send` | **Wrong.** Host-only read; the host mounts no email plugin, so 501. | | `analytics` | `POST /analytics/dataset/query` | **Right when the environment carries analytics** (the route already read the environment's kernel first), **wrong otherwise**: when the environment's kernel carried no analytics service, or could not be acquired, the route fell back to the host's provider instead of answering from the request's environment alone. | ## Tests - **New** `packages/rest/src/rest-environment-scoped-providers.test.ts` (58). It boots real `ObjectKernel`s: a host kernel carrying the real `createRestApiPlugin` (scoping on, `projectResolution: 'auto'`), a `kernel-manager` double with the real manager's dedupe of concurrent builds and build / acquisition counters, a `kernel-resolver` mapping the `Host` header to an environment, and host-tagged occupants of all five slots. Behind it are three environment kernels: `env_alpha` and `env_beta` each carry auth, a protocol, an engine and their own tagged occupants of the five slots, and `env_bare` carries none of the five. Seven doors are driven through the real mounted handlers: approvals list, approvals get, shares list, sharing rules list, email send, the inline dataset query and the record page's approvals part. - **Isolation (the card's hard criterion):** every door × {scoped, hostname} × {A, B} answers 200 with the target environment's row, and exactly one downstream call is recorded, the target's: none from the host, none from the other environment (28 cases). - **No host fallback:** every door × {scoped, hostname} for `env_bare` gets the door's own absent answer (`{ data: [] }` for the list, `{ requests: [], lock: null }` for the record page part, 501 elsewhere) and reaches no occupant at all (14 cases). - **The card's approvals criterion:** environment A's pending request is answered on both mounts for A, and is a 404 through environment B, with B's service the one asked. - **One resolution, one kernel:** a hostname-routed request for a cold environment never calls `resolveKernel` to learn the id, and the kernel manager builds exactly that one environment once. - **Single kernel (the card's second hard criterion):** with no `kernel-manager` and no `kernel-resolver`, every door on both mounts is answered by the one kernel's occupant (14 cases). - **Updated** `packages/rest/src/rest-api-plugin-slot-lookups.test.ts` (8). Each provider row now says whether it is host- or environment-scoped. - The slot-identity pin checks an environment-scoped provider against the environment kernel's occupant for `'env_1'`, and against the host's for no id and for `'platform'`. - The asked-slots pin records the five slots asked on the environment kernel. - A new describe pins the single-kernel reading per provider: for `'env_1'`, `undefined` and `'platform'`, each of the five resolves `ctx.getService(slot)` and makes exactly that one call. ## Acceptance notes - **Changed answer, analytics only.** An environment whose kernel carries no analytics service, or whose kernel cannot be acquired, now gets the route's 501 where it used to get an answer from the host's service. - **Faults keep their existing route answers.** A provider rejection (a kernel that cannot be acquired, a factory that threw) is caught by each route's existing `resolveService` catch, as before, so it answers that door's "not configured" shape (501, `{ data: [] }` or the empty record page part) rather than a 5xx. That is the routes' pre-existing contract and is not changed here. A wedged environment fails first in `resolveExecCtx` on these doors. - **Scope.** The plugin's other provider seams (auth, objectql, i18n, settings, metadata, security, tenancy, endpoint match) are outside this card and are unchanged. - **Cloud's re-read** of `areq_bcb78e81` on staging under cloud#3305 1f follows a pin round that carries this, and is cloud's work, not this PR's. ## Verification - Full `@objectstack/rest` suite under `scripts/pm/os-verify-lock.sh`: 385 files, 7,237 passed, 332 skipped, `VERDICT command-exit 0`. - `pnpm --filter @objectstack/rest typecheck` (`tsc --noEmit` plus `check:test-typecheck` over `tsconfig.test.json`): green, 0 errors, nothing held in the debt ledger. - eslint on all nine touched source and test files: clean. - The 60 commands `node scripts/pm/dispatch-gates.mjs --commands` derives for this diff, each run with its exit code recorded, reconciled by `dispatch-gates --ran`: 59 exit 0, 1 not measured. - `pnpm check:dual-build-cjs-loads` exited 3 (`PREREQUISITE NOT MET`): it reads every package's built `dist/`, and this worktree built only the `@objectstack/rest` closure. CI's whole-tree build runs it. - `node scripts/check-plugin-teardown-shape.mjs --self-test` first exited 3 because this clone is shallow and its pinned fixture commit was missing; after fetching that commit it passed (48 cases). - The text gates (closing-keyword parity, comment-mask adoption and corpus, issue citations, drift comment, affected docs, changeset no-major and empty-changeset, ADR-0087 registration, nul bytes), `check:slot-lookup` and `check:route-envelope` were re-run on the final head `f1334056`: all exit 0. - `node scripts/pm/dispatch-gates.mjs --tier`: no path-derived tier mandate; 861 changed lines, under the human-merge threshold. <details> <summary>Ablation: the hosted-shape test against the base provider and route sources</summary> With `rest-api-plugin.ts`, the five route modules and `rest-server.ts` restored to `73a3ef9f` and the new test file kept, the hosted describe reads **39 failed, 5 passed (44)**. - The 5 that pass are the four analytics cases with the environment present (the route already read the environment's kernel first) and the cold-build case. - The four analytics `env_bare` cases fail: the base route answered them from the host's service. - Every approvals, sharing, sharing-rule, email and record page case fails. With the fix the same file reads 58 passed (58). The single-kernel pins (the new describe and the updated slot-lookup file) pass on both trees, 15 run in the base-source run, which is the "unchanged" half. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QUs3BvMSSmip92aFsGuLjx --- _Generated by [Claude Code](https://claude.ai/code/session_01QUs3BvMSSmip92aFsGuLjx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9d1a465 commit 63b86b3

10 files changed

Lines changed: 765 additions & 95 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest): the approvals, email, sharing, sharing-rule and analytics routes answer from the request's environment kernel when one REST plugin fronts many environments
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **What was wrong.** On a host that mounts one `RestApiPlugin` in front of many environment kernels (the hosted runtime shape), the five provider seams the plugin hands `RestServer` for `approvals`, `email`, `sharing`, `sharingRules` and `analytics` read the HOST kernel's service whatever environment they were asked for, and the unscoped (hostname-routed) mount asked them for no environment at all. The environment's own services were never reached: `GET /api/v1/approvals/requests` answered `{ data: [] }`, `GET /api/v1/approvals/requests/:id` answered 501, and the sharing, sharing-rule and email doors answered 501, on every hosted environment. The record page's approvals part answered no requests.
11+
- **What changes.** Given an environment id and a registered `kernel-manager`, each of the five providers now reads its slot on that environment's kernel (`kernelManager.getOrCreate(id)`), the same acquisition the data routes make. The routes pass the id the shared request→environment resolution answered: the `/environments/:environmentId` param on the scoped mount, else the host's `kernel-resolver` (or the hostname / `X-Environment-Id` chain) on the unscoped one. There is no fallback to the host's occupant: an environment whose kernel carries no such service gets the door's own "not configured" answer.
12+
- **Analytics.** `POST /analytics/dataset/query` already read the environment's service first, and now reads it alone: when the environment's kernel carries no analytics service, or cannot be acquired, the answer is the route's 501 rather than the host's service.
13+
- **Unchanged.** A single-kernel deployment (no `kernel-manager`) reads all five services on the plugin's own kernel exactly as before, for every route and every id. The reserved `platform` id and a request the resolver places in no environment also read the plugin's own kernel.
14+
- **No migration.** No configuration, route, response shape or export changes.

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

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,11 @@
2828
* `register` call, which runs while registration runs, and the class's one
2929
* `routeManager` reassignment wraps `registerMetadataEndpointsInner`, never
3030
* this registrar.
31-
* - `kernelManager` and `analyticsServiceProvider` go as GETTERS. The body's
32-
* `resolveService` reads both at REQUEST time, and neither field is
33-
* `readonly`, so a value copied at registration could part from it. The
34-
* getter reads the field the handler read, when it read it.
31+
* - `analyticsServiceProvider` goes as a GETTER. The body's `resolveService`
32+
* reads it at REQUEST time, and the field is not `readonly`, so a value
33+
* copied at registration could part from it. The getter reads the field the
34+
* handler read, when it read it. (`kernelManager` went the same way until
35+
* #30011 moved the environment-kernel lookup into the provider itself.)
3536
*
3637
* Module-level helpers stay where they were and are imported from the modules
3738
* `rest-server.ts` imports them from. The one helper declared in
@@ -66,12 +67,11 @@ import { mayReadPendingDrafts, type RestServer } from './rest-server.js';
6667
* one of them, and no other. Each is typed AS the class member (an indexed
6768
* access on the class), so a change to a member's signature on the class is a
6869
* change here too, and naming a member the class does not have is a compile
69-
* error. See the module header for why `kernelManager` and
70-
* `analyticsServiceProvider` are getters.
70+
* error. See the module header for why `analyticsServiceProvider` is a
71+
* getter.
7172
*/
7273
export interface AnalyticsRouteHost {
7374
readonly routeManager: RestServer['routeManager'];
74-
readonly kernelManager: RestServer['kernelManager'];
7575
readonly analyticsServiceProvider: RestServer['analyticsServiceProvider'];
7676
resolveRequestEnvironmentId: RestServer['resolveRequestEnvironmentId'];
7777
resolveExecCtx: RestServer['resolveExecCtx'];
@@ -88,23 +88,23 @@ export interface AnalyticsRouteHost {
8888
*/
8989
export function registerAnalyticsEndpoints(this: AnalyticsRouteHost, basePath: string): void {
9090
const isScoped = basePath.includes('/environments/:environmentId');
91-
// Resolve the ENVIRONMENT's analytics service first — its strategy
92-
// bridges are bound to the env kernel's own data engine. The host
93-
// provider (whose 'data' is the host kernel's engine) is only a
94-
// fallback: serving a tenant's dataset query from the host engine
95-
// reads the WRONG database and silently aggregates over nothing
96-
// (the staging "Total Spend: 0 on a populated table" incident).
97-
const resolveService = async (environmentId?: string, req?: any) => {
91+
// The ENVIRONMENT's analytics service — its strategy bridges are bound to
92+
// the env kernel's own data engine. Serving a tenant's dataset query from
93+
// the host engine reads the WRONG database and silently aggregates over
94+
// nothing (the staging "Total Spend: 0 on a populated table" incident).
95+
//
96+
// [#30011] The provider resolves the environment's kernel itself now, as
97+
// its four siblings do, so this asks it for the REQUEST's environment
98+
// (the shared resolver's answer) and nothing else. The host provider is
99+
// no longer a fallback: the door answers from the request's environment
100+
// alone. When the environment's kernel carries no analytics service, or
101+
// could not be acquired, the answer is the 501 below.
102+
const resolveService = async (environmentId: string | undefined, req: any) => {
103+
if (!this.analyticsServiceProvider) return undefined;
98104
try {
99105
const envId = await this.resolveRequestEnvironmentId(environmentId, req);
100-
if (envId && envId !== 'platform' && this.kernelManager) {
101-
const kernel = await this.kernelManager.getOrCreate(envId);
102-
const svc = await kernel.getServiceAsync<any>('analytics').catch(() => undefined);
103-
if (svc) return svc;
104-
}
105-
} catch { /* fall back to the host service */ }
106-
if (!this.analyticsServiceProvider) return undefined;
107-
try { return await this.analyticsServiceProvider(environmentId); }
106+
return await this.analyticsServiceProvider(envId);
107+
}
108108
catch { return undefined; }
109109
};
110110

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

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export const APPROVAL_RECORD_LOCK_PARAMS: readonly string[] = [];
8484
export interface ApprovalsRouteHost {
8585
readonly routeManager: RestServer['routeManager'];
8686
readonly approvalsServiceProvider: RestServer['approvalsServiceProvider'];
87+
resolveRequestEnvironmentId: RestServer['resolveRequestEnvironmentId'];
8788
resolveExecCtx: RestServer['resolveExecCtx'];
8889
enforceAuth: RestServer['enforceAuth'];
8990
}
@@ -102,9 +103,18 @@ export function registerApprovalsEndpoints(this: ApprovalsRouteHost, basePath: s
102103
const dataPath = basePath;
103104
const isScoped = basePath.includes('/environments/:environmentId');
104105

105-
const resolveService = async (environmentId?: string) => {
106+
// [#30011] The provider is asked for the environment the REQUEST is in,
107+
// through the one resolver every REST-owned route shares: the scoped
108+
// param when the mount carries one, else the host's request→environment
109+
// answer (a hostname-routed request arrives here with no id at all).
110+
// Passing the raw `undefined` on the unscoped mount is how a hosted
111+
// environment's approvals were read from the host kernel.
112+
const resolveService = async (environmentId: string | undefined, req: any) => {
106113
if (!this.approvalsServiceProvider) return undefined;
107-
try { return await this.approvalsServiceProvider(environmentId); }
114+
try {
115+
const envId = await this.resolveRequestEnvironmentId(environmentId, req);
116+
return await this.approvalsServiceProvider(envId);
117+
}
108118
catch { return undefined; }
109119
};
110120
const respond501 = (res: any) => res.status(501).json({
@@ -236,7 +246,7 @@ export function registerApprovalsEndpoints(this: ApprovalsRouteHost, basePath: s
236246
const environmentId = isScoped ? req.params?.environmentId : undefined;
237247
const context = await this.resolveExecCtx(environmentId, req);
238248
if (this.enforceAuth(req, res, context)) return;
239-
const svc = await resolveService(environmentId);
249+
const svc = await resolveService(environmentId, req);
240250
if (!svc) {
241251
// No approvals plugin loaded — return empty list rather than 501
242252
// so Console badge polls don't spam the error log on deployments
@@ -332,7 +342,7 @@ export function registerApprovalsEndpoints(this: ApprovalsRouteHost, basePath: s
332342
const environmentId = isScoped ? req.params?.environmentId : undefined;
333343
const context = await this.resolveExecCtx(environmentId, req);
334344
if (this.enforceAuth(req, res, context)) return;
335-
const svc = await resolveService(environmentId);
345+
const svc = await resolveService(environmentId, req);
336346
if (!svc) return respond501(res);
337347
const row = await readableRequestOr404(svc, req.params.id, context, res);
338348
if (!row) return;
@@ -362,7 +372,7 @@ export function registerApprovalsEndpoints(this: ApprovalsRouteHost, basePath: s
362372
const context = await this.resolveExecCtx(environmentId, req);
363373
if (this.enforceAuth(req, res, context)) return;
364374
if (refuseUnknownQueryParams(req, res, APPROVAL_RECORD_LOCK_PARAMS)) return;
365-
const svc = await resolveService(environmentId);
375+
const svc = await resolveService(environmentId, req);
366376
if (!svc || typeof svc.getRecordLock !== 'function') return respond501(res);
367377
const { object, recordId } = req.params;
368378
const lock = await svc.getRecordLock(object, recordId, context ?? {});
@@ -401,7 +411,7 @@ export function registerApprovalsEndpoints(this: ApprovalsRouteHost, basePath: s
401411
const environmentId = isScoped ? req.params?.environmentId : undefined;
402412
const context = await this.resolveExecCtx(environmentId, req);
403413
if (this.enforceAuth(req, res, context)) return;
404-
const svc = await resolveService(environmentId);
414+
const svc = await resolveService(environmentId, req);
405415
if (!svc) return respond501(res);
406416
const body = req.body ?? {};
407417
try {
@@ -441,7 +451,7 @@ export function registerApprovalsEndpoints(this: ApprovalsRouteHost, basePath: s
441451
const environmentId = isScoped ? req.params?.environmentId : undefined;
442452
const context = await this.resolveExecCtx(environmentId, req);
443453
if (this.enforceAuth(req, res, context)) return;
444-
const svc = await resolveService(environmentId);
454+
const svc = await resolveService(environmentId, req);
445455
if (!svc || typeof svc.recall !== 'function') return respond501(res);
446456
const body = req.body ?? {};
447457
try {
@@ -480,7 +490,7 @@ export function registerApprovalsEndpoints(this: ApprovalsRouteHost, basePath: s
480490
const environmentId = isScoped ? req.params?.environmentId : undefined;
481491
const context = await this.resolveExecCtx(environmentId, req);
482492
if (this.enforceAuth(req, res, context)) return;
483-
const svc = await resolveService(environmentId);
493+
const svc = await resolveService(environmentId, req);
484494
if (!svc) return respond501(res);
485495
const body = req.body ?? {};
486496
try {
@@ -529,7 +539,7 @@ export function registerApprovalsEndpoints(this: ApprovalsRouteHost, basePath: s
529539
const environmentId = isScoped ? req.params?.environmentId : undefined;
530540
const context = await this.resolveExecCtx(environmentId, req);
531541
if (this.enforceAuth(req, res, context)) return;
532-
const svc = await resolveService(environmentId);
542+
const svc = await resolveService(environmentId, req);
533543
if (!svc) return respond501(res);
534544
const body = req.body ?? {};
535545
try {
@@ -585,7 +595,7 @@ export function registerApprovalsEndpoints(this: ApprovalsRouteHost, basePath: s
585595
const environmentId = isScoped ? req.params?.environmentId : undefined;
586596
const context = await this.resolveExecCtx(environmentId, req);
587597
if (this.enforceAuth(req, res, context)) return;
588-
const svc = await resolveService(environmentId);
598+
const svc = await resolveService(environmentId, req);
589599
if (!svc) return respond501(res);
590600
// The parent door's lookup and 404 first: an unknown id and a
591601
// request the caller cannot see are not an empty audit trail.

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ function refuse(res: any, message: string): void {
189189
export interface EmailRouteHost {
190190
readonly routeManager: RestServer['routeManager'];
191191
readonly emailServiceProvider: RestServer['emailServiceProvider'];
192+
resolveRequestEnvironmentId: RestServer['resolveRequestEnvironmentId'];
192193
resolveExecCtx: RestServer['resolveExecCtx'];
193194
enforceAuth: RestServer['enforceAuth'];
194195
}
@@ -247,7 +248,14 @@ export function registerEmailEndpoints(this: EmailRouteHost, basePath: string):
247248
// answered the wiring question, and "not configured" and
248249
// "configured and unusable" both mean this deployment cannot
249250
// send mail — one 501, no fact lost by folding them.
250-
const emailService = await seamOrUndefined(() => this.emailServiceProvider!(environmentId));
251+
//
252+
// [#30011] Asked for the REQUEST's environment — the scoped
253+
// param, else the shared request→environment resolver — so a
254+
// hosted environment's send goes through its own kernel's
255+
// email service, never the host's. Resolved inside the seam,
256+
// so a resolver fault takes the same 501 as any other.
257+
const emailService = await seamOrUndefined(async () =>
258+
this.emailServiceProvider!(await this.resolveRequestEnvironmentId(environmentId, req)));
251259
if (!emailService || typeof emailService.send !== 'function') {
252260
res.status(501).json({
253261
code: 'NOT_IMPLEMENTED',

‎packages/rest/src/record-page-routes.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export interface RecordPageRouteHost {
8484
readonly routeManager: RestServer['routeManager'];
8585
readonly approvalsServiceProvider: ((environmentId?: string) => Promise<any | undefined>) | undefined;
8686
resolveProtocol: RestServer['resolveProtocol'];
87+
resolveRequestEnvironmentId: RestServer['resolveRequestEnvironmentId'];
8788
resolveExecCtx: RestServer['resolveExecCtx'];
8889
enforceAuth: RestServer['enforceAuth'];
8990
enforceApiAccess: RestServer['enforceApiAccess'];
@@ -429,7 +430,14 @@ export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath:
429430
const answerApprovals = async () => {
430431
try {
431432
const provider = this.approvalsServiceProvider;
432-
const svc = provider ? await provider(environmentId).catch(() => undefined) : undefined;
433+
// [#30011] The REQUEST's environment, through the shared
434+
// resolver, as the `/approvals` door asks it — the raw
435+
// `undefined` of the unscoped mount read the host's.
436+
const svc = provider
437+
? await this.resolveRequestEnvironmentId(environmentId, req)
438+
.then((envId) => provider(envId))
439+
.catch(() => undefined)
440+
: undefined;
433441
if (!svc) return { requests: [], lock: null };
434442
const rows: any[] = (await svc.listRequests({ object, recordId: id }, context ?? {})) ?? [];
435443
const pending = rows.find((r) => r?.status === 'pending');

0 commit comments

Comments
 (0)