diff --git a/.changeset/discovery-email-route-key.md b/.changeset/discovery-email-route-key.md new file mode 100644 index 0000000000..ecb10ba4eb --- /dev/null +++ b/.changeset/discovery-email-route-key.md @@ -0,0 +1,41 @@ +--- +"@objectstack/spec": minor +"@objectstack/rest": minor +"@objectstack/client": patch +--- + +feat(spec,rest,client): the email surface becomes discoverable and the SDK follows the advertised base; the scoped client derives its prefix from discovery (#6714) + +`@objectstack/client` 的 `email.send` 硬编码 `${baseUrl}/api/v1/email/send`,而服务端 +`registerEmailEndpoints` 挂在 `getApiBasePath()` 下、**已经跟随 `apiPath`** —— 设了 +`apiPath` 的部署上这是**现活 404**,不是潜伏项。实测(`apiPath: '/backend/api/v9'` +启动,录制挂载表):email 面只有 `POST /backend/api/v9/email/send` 一条, +`POST /api/v1/email/send` 在表中**不存在**。`ScopedProjectClient.scope()` 同样硬编码 +`/api/v1/environments/...`,scoped 面全部 `meta` / `data` / `batch` / `packages` / +`automation` URL 由它拼出;同一启动下 83 条 scoped 路由全在 +`/backend/api/v9/environments/:environmentId/...`,`/api/v1/environments/` 前缀零挂载。 + +按维护者裁定(2026-08-08)复刻 #6633 / PR #6712 的四车道模式: + +- **spec**(minor,纯增量):`ApiRoutesSchema` 声明 `email` 键 —— `POST {email}/send` + 的挂载 base。`optional` 同 `datasources`:缺席 = 未挂载。 +- **rest**(minor):`/discovery` 把 `routes.email` 作为**已录制挂载**的投影通告 + (RouteManager 表中 `registerEmailEndpoints` 写入的那一行,mounted ⇒ advertised, + 不二次计算)—— 挂载随 `apiPath` 移动时,通告按构造随行。未挂载 ⇒ 不通告。 + 奇偶钉(`discovery-advertised-direct-mounts.parity.test.ts`)扩展覆盖 email: + 通告值 + `/send` 必须在同一张挂载表里解析得到,单侧移动即红。 +- **client**(patch,行为修复):`email.send` 走 `getRoute('email')`; + `ScopedProjectClient.scope()` 从通告的 `routes.data` base 推导 scoped 前缀。 + 未连接、或服务端未通告 / 不可推导时,回退 URL 与旧硬编码**逐字节一致**。 + +面 3 为何用 `routes.data` 而不是 `scoping` 块:实测 discovery 的 `scoping` 只有 +`enabled` / `resolution` / `scoped` / `environmentId` 四个键,**全是姿态、无路径**, +无法推导 base;`routes.data` 由 rest 通告为 `{realBase}{crud.dataPrefix}`,是唯一可 +推导的来源。`dataPrefix` 被改成非 `/data` 时推导主动放弃、回退惯例(不做宽松再解析)。 + +`cloud.environments.*` 面(约 30 处)经测量**未改**:本仓无任何宿主挂载 `/cloud/*` —— +`@objectstack/rest` 的路由台账(`rest-route-ledger.ts`,由双向 conformance 门禁保证 +穷尽)cloud 行数为 **0**;runtime dispatcher 无 cloud domain(无 `handleCloud`、无 +`domains/cloud.ts`),且显式把 `/cloud` 列为他宿主的控制面(`skipPaths`)。而 `apiPath` +是 `@objectstack/rest` 独有配置项 —— 该面不随 `apiPath` 移动,按裁定「不随则不收敛」 +保持原样。 diff --git a/content/docs/references/api/discovery.mdx b/content/docs/references/api/discovery.mdx index c3c76f6918..f646c9e9aa 100644 --- a/content/docs/references/api/discovery.mdx +++ b/content/docs/references/api/discovery.mdx @@ -47,6 +47,7 @@ const result = ApiRoutesSchema.parse(data); | **analytics** | `string` | optional | e.g. /api/v1/analytics | | **packages** | `string` | optional | e.g. /api/v1/packages | | **datasources** | `string` | optional | e.g. /api/v1/datasources — base for the datasources/:name/external/* federation-admin family; absent when no host mounts it | +| **email** | `string` | optional | e.g. /api/v1/email — base for the email/send endpoint; absent when no host mounts it | | **approvals** | `string` | optional | e.g. /api/v1/approvals | | **realtime** | `string` | optional | e.g. /api/v1/realtime | | **notifications** | `string` | optional | e.g. /api/v1/notifications | diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 57a51d3bb3..ac198978b3 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -558,6 +558,36 @@ describe('Data actions, email, dataset query, external datasources (#3587 gap cl await client.datasources.external.validate('pg_main'); expect(String(fetchMock.mock.calls[5][0])).toBe(`${base}/validate`); }); + + // [#6714] Face 1: `email.send` joins `getRoute()`. Case A is the pin test + // above ('email.send pins POST /email/send') — unconnected ⇒ the + // `/api/v1/email/send` convention, byte-identical to the pre-#6714 + // hardcode. B and C cover the discovery-following half. + it('[#6714] discovery WITHOUT an email key leaves the convention untouched (case B)', async () => { + const { client, fetchMock } = createMockClient({ status: 'sent' }); + // A server rebased to /backend/api/v9 that does not advertise the + // email key — exactly what a pre-#6714 rest surface answers. + (client as any)['discoveryInfo'] = { + routes: { data: '/backend/api/v9/data', metadata: '/backend/api/v9/meta' }, + }; + await client.email.send({ to: 'a@example.com', subject: 'Hello', text: 'hi' }); + expect(String(fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/email/send'); + }); + + it('[#6714] email.send follows the advertised rebased routes.email (case C)', async () => { + const { client, fetchMock } = createMockClient({ status: 'sent' }); + (client as any)['discoveryInfo'] = { + routes: { + data: '/backend/api/v9/data', + metadata: '/backend/api/v9/meta', + email: '/backend/api/v9/email', + }, + }; + await client.email.send({ to: 'a@example.com', subject: 'Hello', text: 'hi' }); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe('http://localhost:3000/backend/api/v9/email/send'); + expect(init.method).toBe('POST'); + }); }); describe('Approvals namespace (ADR-0019)', () => { @@ -1609,6 +1639,106 @@ describe('ScopedProjectClient', () => { expect(scoped.getProjectId()).toBe('00000000-0000-0000-0000-000000000001'); }); + // [#6714 face 3] The scoped prefix derives from the advertised + // `routes.data` base (the `scoping` block carries posture only — no path + // — so `routes.data` is the one derivable source). Case A = the pin tests + // above: unconnected ⇒ byte-identical `/api/v1/environments/...`. B and C + // below cover the derivation half. + it('[#6714] scoped prefix follows the advertised base of routes.data (case C) — every namespace', async () => { + const { client, fetchMock } = createMockClient({ ok: true, types: [] }); + (client as any)['discoveryInfo'] = { + routes: { data: '/backend/api/v9/data', metadata: '/backend/api/v9/meta' }, + }; + const scoped = client.project('proj-123'); + const base = 'http://localhost:3000/backend/api/v9/environments/proj-123'; + + // All namespaces build off ONE scope() — drive one method from each so + // a half-fix (some namespaces re-hardcoding the prefix) cannot stay + // green. + await scoped.meta.getTypes(); + expect(String(fetchMock.mock.calls[0][0])).toBe(`${base}/meta`); + await scoped.data.get('task', 't1'); + expect(String(fetchMock.mock.calls[1][0])).toBe(`${base}/data/task/t1`); + await scoped.packages.list(); + expect(String(fetchMock.mock.calls[2][0])).toBe(`${base}/packages`); + await scoped.automation.getFlow('flow-1'); + expect(String(fetchMock.mock.calls[3][0])).toBe(`${base}/automation/flow-1`); + await scoped.data.batchTransaction([{ operation: 'create', object: 'task', data: {} } as any]); + expect(String(fetchMock.mock.calls[4][0])).toBe(`${base}/batch`); + }); + + it('[#6714] a custom dataPrefix makes the base underivable — the convention holds, byte-identical (case B)', async () => { + const { client, fetchMock } = createMockClient({ types: [] }); + // routes.data does not end with the conventional `/data`, so the base + // cannot be derived honestly; the client must NOT guess (contract-first + // — no lenient re-parsing) and falls back to the convention, + // byte-identical to the pre-#6714 behavior. + (client as any)['discoveryInfo'] = { + routes: { data: '/backend/api/v9/records', metadata: '/backend/api/v9/meta' }, + }; + await client.project('proj-123').meta.getTypes(); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/environments/proj-123/meta', + ); + }); + + it('[#6714] a scoped discovery response strips its OWN /environments/{id} segment before re-scoping', async () => { + const { client, fetchMock } = createMockClient({ types: [] }); + // Discovery answered from the environment-scoped mount: routes.data is + // `{base}/environments/{served-id}/data` and scoping says so. The + // derived base must be the UNSCOPED one, so a scoped client for a + // DIFFERENT environment does not stack two scope segments. + (client as any)['discoveryInfo'] = { + routes: { + data: '/backend/api/v9/environments/env-served/data', + metadata: '/backend/api/v9/environments/env-served/meta', + }, + scoping: { enabled: true, resolution: 'auto', scoped: true, environmentId: 'env-served' }, + }; + await client.project('proj-other').meta.getTypes(); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/backend/api/v9/environments/proj-other/meta', + ); + }); + + it('[#6714] a scoped response whose environmentId the host never resolved still strips ONE scope segment', async () => { + const { client, fetchMock } = createMockClient({ types: [] }); + // `rest-server.ts` advertises `scoping.environmentId` as + // `req.params?.environmentId` — a host that did not populate the route + // param answers `scoped: true` with NO id, and `routes.data` keeps the + // literal `:environmentId`. Stripping on the strength of `scoped` alone + // is sound (a scoped base ends with that segment by construction) and + // is what keeps `scope()` from stacking two scope segments. + (client as any)['discoveryInfo'] = { + routes: { + data: '/backend/api/v9/environments/:environmentId/data', + metadata: '/backend/api/v9/environments/:environmentId/meta', + }, + scoping: { enabled: true, resolution: 'auto', scoped: true }, + }; + await client.project('proj-other').meta.getTypes(); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/backend/api/v9/environments/proj-other/meta', + ); + }); + + it('[#6714] scoped:true with no recognisable scope segment DECLINES — convention, never a doubled prefix', async () => { + const { client, fetchMock } = createMockClient({ types: [] }); + // A base the derivation does not understand: `scoped` claims the + // response came off the scoped mount, but `routes.data` carries no + // `/environments/{seg}` to remove. Returning it unchanged would build + // `…/tenants/t1/environments/proj-other/meta` — a URL neither mount + // serves, i.e. strictly worse than the hardcode. Decline instead. + (client as any)['discoveryInfo'] = { + routes: { data: '/backend/api/v9/tenants/t1/data' }, + scoping: { enabled: true, resolution: 'auto', scoped: true }, + }; + await client.project('proj-other').meta.getTypes(); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/environments/proj-other/meta', + ); + }); + it('prefixes the screen-flow automation.resume / getScreen calls', async () => { const { client, fetchMock } = createMockClient({ success: true, data: { success: true } }); const scoped = client.project('proj-123'); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index dc05d13ad2..f49623cc23 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1046,7 +1046,15 @@ export class ObjectStackClient { sentBy?: string; [k: string]: any; }): Promise => { - const res = await this.fetch(`${this.baseUrl}/api/v1/email/send`, { + // [#6714] The base comes from `getRoute('email')`: a connected client + // follows the server's advertised `routes.email` (the REST discovery + // endpoint projects it from its recorded route registrations — the + // mount follows `apiPath`, so the old hard-coded `/api/v1` was a live + // 404 on any `apiPath` deployment); an unconnected client — or one + // talking to a server that advertises no `email` key — falls back to + // the `/api/v1/email` convention, byte-identical to the old hardcode. + const route = this.getRoute('email'); + const res = await this.fetch(`${this.baseUrl}${route}/send`, { method: 'POST', body: JSON.stringify(message), }); @@ -1733,6 +1741,59 @@ export class ObjectStackClient { /** @internal */ _isFilterAST(v: unknown): boolean { return this.isFilterAST(v); } + /** + * @internal The unscoped API base this client's server actually serves, + * derived from the advertised routes (#6714 face 3). + * + * There is no discovery key that carries the raw API base itself, and the + * `scoping` block carries posture only (`enabled` / `resolution` / `scoped` + * / `environmentId` — no path), so the one derivable source is + * `routes.data`: the REST discovery endpoint advertises it as + * `{realBase}{dataPrefix}` with `dataPrefix` defaulting to `/data`. This + * derivation strips that conventional suffix; when the deployment customises + * `dataPrefix` away from `/data` the derivation declines and the caller + * falls back to the `/api/v1` convention — exactly today's behavior, so the + * change is strictly "follow the advertised base when it is derivable". + * + * When the discovery response was served from the environment-scoped mount + * (`scoping.scoped`), `routes.data` is `{base}/environments/{id}/data`; the + * scope segment must come off so the returned base is the UNSCOPED one (the + * scoped client re-appends its own environment id, which need not be the one + * discovery resolved). `scoping.environmentId` names that segment when the + * server resolved one — but rest advertises it as `req.params?.environmentId`, + * so a host that did not populate the route param answers `scoped: true` with + * NO id and a `routes.data` still carrying the literal `:environmentId`. That + * case strips one trailing `/environments/{segment}` on the strength of + * `scoped` alone, which is sound because a scoped response's base ends with + * that segment by construction. If NEITHER shape is present the advertised + * base is not one this derivation understands, so it declines rather than + * return a base of unknown shape — handing back a still-scoped base would make + * `scope()` build a doubled `/environments/…/environments/…` URL, i.e. + * strictly WORSE than the hardcode this replaces. Declining is always + * byte-identical to today. + */ + _apiBase(): string { + const data = this.discoveryInfo?.routes?.data; + if (typeof data === 'string' && data.endsWith('/data')) { + let base = data.slice(0, -'/data'.length); + const scoping = this.discoveryInfo?.scoping; + if (scoping?.scoped) { + const advertised = typeof scoping.environmentId === 'string' && scoping.environmentId + ? `/environments/${scoping.environmentId}` + : undefined; + if (advertised && base.endsWith(advertised)) { + base = base.slice(0, -advertised.length); + } else { + const stripped = base.replace(/\/environments\/[^/]+$/, ''); + if (stripped === base) return '/api/v1'; + base = stripped; + } + } + if (base) return base; + } + return '/api/v1'; + } + /** * Organization Services * @@ -4812,8 +4873,16 @@ export class ObjectStackClient { // hardcode — the fallback agrees with the mount instead of competing // with it. datasources: '/api/v1/datasources', + // [#6714] `email` became a declared `ApiRoutes` key (the base under + // which `POST {email}/send` is mounted), and this map is TOTAL over + // declared keys by design. `/api/v1/email` is not a guess: it is where + // `@objectstack/rest` mounts the surface on a default-base boot, so an + // unconnected client builds byte-identical URLs to the pre-#6714 + // hardcode — the fallback agrees with the mount instead of competing + // with it. + email: '/api/v1/email', }; - + return routeMap[type] || `/api/v1/${type}`; } } @@ -4843,8 +4912,19 @@ export class ScopedProjectClient { /** The environmentId this client is scoped to. */ getProjectId(): string { return this.environmentId; } - /** Prefix segment inserted between the baseUrl and the resource path. */ - private scope(): string { return `/api/v1/environments/${encodeURIComponent(this.environmentId)}`; } + /** + * Prefix segment inserted between the baseUrl and the resource path. + * + * [#6714 face 3] The API base comes from the parent's discovery-derived + * `_apiBase()` rather than a hard-coded `/api/v1`: the server's scoped + * mount point is `getScopedBasePath(getApiBasePath())`, which follows + * `apiPath`, so a scoped client talking to an `apiPath` deployment built + * 404 URLs for every `meta` / `data` / `batch` / `packages` / `automation` + * call. An unconnected parent — or one whose advertised routes the base + * cannot be derived from — keeps building byte-identical + * `/api/v1/environments/...` URLs. + */ + private scope(): string { return `${this.parent._apiBase()}/environments/${encodeURIComponent(this.environmentId)}`; } private url(suffix: string): string { return `${this.parent._baseUrl()}${this.scope()}${suffix}`; diff --git a/packages/metadata-protocol/src/discovery-schema-conformance.test.ts b/packages/metadata-protocol/src/discovery-schema-conformance.test.ts index f9e7e92c1c..db61f8ba27 100644 --- a/packages/metadata-protocol/src/discovery-schema-conformance.test.ts +++ b/packages/metadata-protocol/src/discovery-schema-conformance.test.ts @@ -157,6 +157,20 @@ describe('[#4828] getDiscovery() conforms to DiscoverySchema', () => { expect(Object.prototype.hasOwnProperty.call(discovery.routes, 'datasources')).toBe(false); expect(declaredRouteKeys().has('datasources')).toBe(true); }); + + it('[#6714] does NOT advertise `email` — this builder knows nothing about the email mount', async () => { + // Same disposition as `datasources` above: `registerEmailEndpoints` + // mounts `POST {base}/email/send` on the REST host (unconditionally, + // 501-degrading when no email service is configured), which this builder + // cannot see — and the runtime dispatcher serves no /email domain at + // all. Advertising here would be the advertise-the-unmounted half of + // ADR-0076 D12. The REST discovery endpoint advertises it from its + // recorded route registrations. + const discovery: any = await makeImpl().getDiscovery(); + + expect(Object.prototype.hasOwnProperty.call(discovery.routes, 'email')).toBe(false); + expect(declaredRouteKeys().has('email')).toBe(true); + }); }); // ═════════════════════════════════════════════════════════════════════════ diff --git a/packages/rest/src/discovery-advertised-direct-mounts.parity.test.ts b/packages/rest/src/discovery-advertised-direct-mounts.parity.test.ts index 2dd6dacf63..c475147c04 100644 --- a/packages/rest/src/discovery-advertised-direct-mounts.parity.test.ts +++ b/packages/rest/src/discovery-advertised-direct-mounts.parity.test.ts @@ -99,6 +99,8 @@ function boot(opts: { withPackageService?: boolean; enableProjectScoping?: boolean; projectResolution?: string; + /** [#6714] Rebase the RestServer's OWN mounts (`getApiBasePath()`). */ + apiPath?: string; }) { const { server, table } = createRecordingServer(); const engine = { @@ -110,6 +112,7 @@ function boot(opts: { const protocol = new ObjectStackProtocolImplementation(engine as any, () => services); const config: any = { api: { + ...(opts.apiPath ? { apiPath: opts.apiPath } : {}), ...(opts.enableProjectScoping ? { enableProjectScoping: true, projectResolution: opts.projectResolution ?? 'auto' } : {}), @@ -154,6 +157,15 @@ describe('[#6633] /discovery advertises the direct-mount surfaces where they are expect(discovery.routes.packages).toBe('/api/v1/packages'); expect(discovery.routes.datasources).toBe('/api/v1/datasources'); + // [#6714] …and the email surface, projected from the RouteManager's + // recorded registrations rather than the direct-mount arrays: advertised + // base + `/send` must be a mounted POST route in the SAME table. + expect(discovery.routes.email).toBe('/api/v1/email'); + expect( + resolveRoute(table, 'POST', `${discovery.routes.email}/send`), + 'advertised routes.email must be the base of the mounted send route', + ).toBeDefined(); + // …and the advertised URLs answer through the SAME mounted table. const pkg = resolveRoute(table, 'GET', discovery.routes.packages); expect(pkg, 'advertised routes.packages must be a mounted GET route').toBeDefined(); @@ -191,6 +203,14 @@ describe('[#6633] /discovery advertises the direct-mount surfaces where they are // would have been the lie the projection exists to prevent. expect(resolveRoute(table, 'GET', '/api/v1/packages')).toBeUndefined(); expect(resolveRoute(table, 'GET', '/api/v1/datasources/pg_main/external/tables')).toBeUndefined(); + + // [#6714] The email surface is NOT a direct mount — it registers at the + // RestServer's OWN base, which this boot leaves at the default. The two + // families genuinely diverge on this boot, and each advertisement tells + // its own mount's truth: per-face keys projected per-face, never one + // shared "known base" assumption (the scheme the #6714 ruling rejected). + expect(discovery.routes.email).toBe('/api/v1/email'); + expect(resolveRoute(table, 'POST', '/api/v1/email/send')).toBeDefined(); }); it('not mounted ⇒ not advertised: a boot without the package service advertises no routes.packages', async () => { @@ -208,6 +228,32 @@ describe('[#6633] /discovery advertises the direct-mount surfaces where they are // it IS advertised on the same boot. expect(discovery.routes.datasources).toBe('/api/v1/datasources'); expect(resolveRoute(table, 'GET', '/api/v1/datasources/pg_main/external/tables')).toBeDefined(); + + // [#6714] Same for the email surface: `registerEmailEndpoints` mounts + // unconditionally (501-degrading when no email service is configured), so + // it is advertised on every boot — mounted is the fact, not configured. + expect(discovery.routes.email).toBe('/api/v1/email'); + expect(resolveRoute(table, 'POST', '/api/v1/email/send')).toBeDefined(); + }); + + it('[#6714] routes.email follows apiPath — the mount moved, the advertisement moved with it, and the old convention path is GONE', async () => { + // THE live-404 case from the card: `registerEmailEndpoints` mounts under + // `getApiBasePath()`, which follows `apiPath`. The pre-#6714 client + // hard-coded `POST {baseUrl}/api/v1/email/send`, so on this boot it hit a + // path that resolves to NOTHING in the mounted table — a live 404 today, + // not a latent gap. The advertisement is a projection of the recorded + // RouteManager rows, so it moves with the mount by construction. + const { table } = boot({ versionedBase: '/backend/api/v9', apiPath: '/backend/api/v9' }); + const discovery = await readDiscovery(table, '/backend/api/v9'); + + expect(discovery.routes.email).toBe('/backend/api/v9/email'); + expect( + resolveRoute(table, 'POST', `${discovery.routes.email}/send`), + 'advertised routes.email must answer where the surface is actually mounted', + ).toBeDefined(); + + // The pre-#6714 client URL — the measured live 404. + expect(resolveRoute(table, 'POST', '/api/v1/email/send')).toBeUndefined(); }); it('scoped discovery advertises the scoped packages mount with the environment id substituted', async () => { @@ -223,8 +269,16 @@ describe('[#6633] /discovery advertises the direct-mount surfaces where they are // truth, and the scoped response says so rather than inventing one. expect(discovery.routes.datasources).toBe('/api/v1/datasources'); + // [#6714] The email surface DOES have a scoped variant (`registerForBase` + // runs the email registrar under both bases in `auto`), and the scoped + // response advertises it with the environment id substituted — resolving + // against the genuinely mounted `:environmentId` pattern. + expect(discovery.routes.email).toBe('/api/v1/environments/env_alpha/email'); + expect(resolveRoute(table, 'POST', '/api/v1/environments/env_alpha/email/send')).toBeDefined(); + // The unscoped response keeps the unscoped mount. const unscoped = await readDiscovery(table); expect(unscoped.routes.packages).toBe('/api/v1/packages'); + expect(unscoped.routes.email).toBe('/api/v1/email'); }); }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 76e5fa0b19..98b8c4870c 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3480,6 +3480,22 @@ export class RestServer { else delete discovery.routes.packages; if (direct.datasources) discovery.routes.datasources = direct.datasources; else delete discovery.routes.datasources; + + // [#6714] Email surface — same mounted ⇒ advertised + // discipline, over the RouteManager recording: + // `registerEmailEndpoints` registers + // `POST {base}/email/send` at THIS server's base (it + // follows `apiPath`), and the advertisement is a + // projection of that recorded row — never recomputed — + // so the SDK's `getRoute('email')` follows the real + // mount instead of the `/api/v1` convention the client + // used to hard-code (a live 404 on any `apiPath` + // deployment). Not mounted ⇒ not advertised. + const emailBase = this.getMountedEmailRouteBase( + isScoped ? (req.params?.environmentId ?? ':environmentId') : undefined, + ); + if (emailBase) discovery.routes.email = emailBase; + else delete discovery.routes.email; } // Cross-object atomic batch capability (#3298). `declared === @@ -9356,6 +9372,51 @@ export class RestServer { return { packages, datasources }; } + /** + * [#6714] The advertised base for the email surface, projected from the + * RECORDED route registrations — never recomputed from config. + * + * Same mounted ⇒ advertised discipline as {@link getDirectMountRouteBases} + * (ADR-0076 D12), over the other recording: `registerEmailEndpoints` + * registers `POST {base}/email/send` through the RouteManager, so the + * RouteManager's table — the very rows the registrar wrote to mount — is + * the mount fact this method projects. A future change that moves the + * email mount moves the advertisement with it, and a change that touches + * only one side goes red on the parity pin + * (`discovery-advertised-direct-mounts.parity.test.ts`). + * + * @param scopedEnvironmentId when the discovery response being built is + * served from the environment-scoped mount, the resolved environment id + * (or the `:environmentId` placeholder when unresolved); `undefined` for + * the unscoped mount. + * @returns the advertised `routes.email` base (`{mountBase}/email` — the + * consumer appends `/send`), or `undefined` when no email route is + * recorded for this boot. + */ + getMountedEmailRouteBase(scopedEnvironmentId?: string): string | undefined { + const SCOPED_SEGMENT = '/environments/:environmentId'; + const SEND_SUFFIX = '/email/send'; + let unscoped: string | undefined; + let scoped: string | undefined; + for (const { method, path } of this.routeManager.getAll()) { + // The email registrar's send route (`POST {base}/email/send`) IS + // the surface: the advertised base is the recorded path minus the + // `/send` leaf — recognised, never rebuilt. + if (method !== 'POST' || !path.endsWith(SEND_SUFFIX)) continue; + const base = path.slice(0, -'/send'.length); + if (path.includes(SCOPED_SEGMENT)) scoped = base; + else unscoped = base; + } + // Same scoped/unscoped selection as the packages projection above: a + // scoped discovery response advertises the scoped mount when one is + // recorded (environment id substituted), the unscoped mount answers + // everywhere else, and an unscoped caller is never handed a + // `:environmentId` pattern nothing can consume. + return scopedEnvironmentId !== undefined + ? (scoped?.replace(':environmentId', scopedEnvironmentId) ?? unscoped) + : unscoped; + } + /** * Get all routes mounted for this boot — the whole surface this server * knows about, RouteManager's table and the recorded direct mounts alike. diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 49d16b75f7..be3ecf5752 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -146,6 +146,7 @@ "api/ApiRoutes:data", "api/ApiRoutes:datasources", "api/ApiRoutes:discovery", + "api/ApiRoutes:email", "api/ApiRoutes:i18n", "api/ApiRoutes:mcp", "api/ApiRoutes:metadata", diff --git a/packages/spec/src/api/discovery.test.ts b/packages/spec/src/api/discovery.test.ts index 397d887172..a54326dfa3 100644 --- a/packages/spec/src/api/discovery.test.ts +++ b/packages/spec/src/api/discovery.test.ts @@ -113,6 +113,24 @@ describe('ApiRoutesSchema', () => { expect(minimal.packages).toBeUndefined(); expect(minimal.datasources).toBeUndefined(); }); + + // [#6714] The email surface key — base under which `POST {email}/send` is + // mounted. Optional: absent = not mounted (ADR-0076 D12); a rebased + // deployment advertises its real base and the SDK follows it. + it('accepts the email surface key, rebased or absent', () => { + const rebased = ApiRoutesSchema.parse({ + data: '/backend/api/v9/data', + metadata: '/backend/api/v9/meta', + email: '/backend/api/v9/email', + }); + expect(rebased.email).toBe('/backend/api/v9/email'); + + const minimal = ApiRoutesSchema.parse({ + data: '/api/v1/data', + metadata: '/api/v1/meta', + }); + expect(minimal.email).toBeUndefined(); + }); }); /** Minimal services map used as base fixture for DiscoverySchema tests */ diff --git a/packages/spec/src/api/discovery.zod.ts b/packages/spec/src/api/discovery.zod.ts index 22602d4047..ce6e25ec49 100644 --- a/packages/spec/src/api/discovery.zod.ts +++ b/packages/spec/src/api/discovery.zod.ts @@ -210,6 +210,31 @@ export const ApiRoutesSchema = lazySchema(() => z.object({ + 'absent when no host mounts it' ), + /** + * Base URL for the transactional-email surface — the base under which + * `POST {email}/send` is mounted. + * + * Declared by #6714 (replicating the #6633 / `datasources` precedent): the + * SDK's `email.send` hard-coded `/api/v1/email/send`, while the REST + * server's `registerEmailEndpoints` mounts under `getApiBasePath()` and + * therefore already follows `apiPath` — so on any `apiPath` deployment the + * stock client's email.send was a live 404, not a latent gap. With the key + * declared, the host that mounts the surface advertises WHERE, and the SDK + * follows — falling back to the `/api/v1/email` convention when + * unadvertised. + * + * `optional`, not `nullable` — same reasoning as `datasources`: the key is + * ABSENT when no email surface is mounted. The runtime dispatcher serves no + * `/email` domain, so it must never advertise one (ADR-0076 D12), and the + * `getDiscovery()` builder in `metadata-protocol` emits nothing here either + * — the mount belongs to the REST host, which that builder cannot see. The + * one producer that CAN answer truthfully is the REST discovery endpoint, + * which projects the value from its recorded route registrations. + */ + email: z.string().optional().describe( + 'e.g. /api/v1/email — base for the email/send endpoint; absent when no host mounts it' + ), + // `workflow` was removed here (#4451, v17): no host ever mounted a workflow // surface and nothing ever registered the slot (ADR-0115 Evidence 5), so no // builder could truthfully populate the field. State machines are enforced