From d8d46249fd0bf9204c5f88198040d753f4286f4e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 17:46:12 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(spec,rest,client):=20email=20surface?= =?UTF-8?q?=20joins=20getRoute()=20=E2=80=94=20spec=20key,=20projected=20a?= =?UTF-8?q?dvertisement,=20client=20follows=20(#6714)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --- packages/client/src/client.test.ts | 92 +++++++++++++++++++ packages/client/src/index.ts | 69 +++++++++++++- .../src/discovery-schema-conformance.test.ts | 14 +++ ...ry-advertised-direct-mounts.parity.test.ts | 54 +++++++++++ packages/rest/src/rest-server.ts | 61 ++++++++++++ packages/spec/src/api/discovery.test.ts | 18 ++++ packages/spec/src/api/discovery.zod.ts | 25 +++++ 7 files changed, 329 insertions(+), 4 deletions(-) diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 57a51d3bb3..caf579970c 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,68 @@ 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('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..ec2e1b392b 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,40 @@ 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 + * advertised `scoping.environmentId` names the segment to strip 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). + */ + _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 && typeof scoping.environmentId === 'string' && scoping.environmentId) { + const scopedSuffix = `/environments/${scoping.environmentId}`; + if (base.endsWith(scopedSuffix)) base = base.slice(0, -scopedSuffix.length); + } + if (base) return base; + } + return '/api/v1'; + } + /** * Organization Services * @@ -4812,8 +4854,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 +4893,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 1cbae8b910..8e33851652 100644 --- a/packages/rest/src/discovery-advertised-direct-mounts.parity.test.ts +++ b/packages/rest/src/discovery-advertised-direct-mounts.parity.test.ts @@ -95,6 +95,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 = { @@ -106,6 +108,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' } : {}), @@ -150,6 +153,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(); @@ -187,6 +199,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 () => { @@ -204,6 +224,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 () => { @@ -219,8 +265,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 ca5f75d124..ed64c6256a 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3463,6 +3463,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 === @@ -9339,6 +9355,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/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 From 908326db72f2dcbc1d0cfcb19ed7ec6cce1c1bd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 01:38:39 +0000 Subject: [PATCH 2/3] chore(spec,docs): regenerate authorable-surface + discovery reference from the MERGED source (#6714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuild, not text-merge (AGENTS.md §11). `check:authorable-surface` on the merged tree named the delta as exactly `api/ApiRoutes:email` before the regen; both artifacts came back with exactly one added line each and nothing else moved, so main's newer keys are intact. Changeset rewritten to the measurements taken on the merged tree (the apiPath mount table for faces 1/3, the `scoping` block's key set for the face-3 derivation choice, and the face-2 mount-ownership census). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --- .changeset/discovery-email-route-key.md | 41 +++++++++++++++++++++++ content/docs/references/api/discovery.mdx | 1 + packages/spec/authorable-surface/api.json | 1 + 3 files changed, 43 insertions(+) create mode 100644 .changeset/discovery-email-route-key.md 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/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", From 44a9f01ffc184fdbc6877a6548d622be4e7dda87 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 02:00:41 +0000 Subject: [PATCH 3/3] fix(client): _apiBase() declines a scoped base it cannot un-scope, instead of returning one (#6714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of face 3 found a real edge the first cut got wrong. rest advertises `scoping.environmentId` as `req.params?.environmentId`, so a host that did not populate the route param answers `scoped: true` with NO id while `routes.data` still carries the literal `:environmentId`. The old derivation skipped its strip in that case and returned a base that was STILL scoped — `scope()` would then build `…/environments/:environmentId/environments//meta`, a URL neither mount serves. That is strictly worse than the hardcode this card replaces, which is the one outcome the byte-identical-fallback contract forbids. Now: strip the advertised id when it matches; else strip one trailing `/environments/{segment}` on the strength of `scoped` alone (sound — a scoped base ends with that segment by construction); else DECLINE to the `/api/v1` convention rather than return a base of unknown shape. Two pins added, both reverse-verified: removing the decline branch turns the second one red with exactly the doubled-prefix URL it forbids. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --- packages/client/src/client.test.ts | 38 ++++++++++++++++++++++++++++++ packages/client/src/index.ts | 31 +++++++++++++++++++----- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index caf579970c..ac198978b3 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -1701,6 +1701,44 @@ describe('ScopedProjectClient', () => { ); }); + 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 ec2e1b392b..f49623cc23 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1757,18 +1757,37 @@ export class ObjectStackClient { * * When the discovery response was served from the environment-scoped mount * (`scoping.scoped`), `routes.data` is `{base}/environments/{id}/data`; the - * advertised `scoping.environmentId` names the segment to strip 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). + * 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 && typeof scoping.environmentId === 'string' && scoping.environmentId) { - const scopedSuffix = `/environments/${scoping.environmentId}`; - if (base.endsWith(scopedSuffix)) base = base.slice(0, -scopedSuffix.length); + 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; }