From d61072d85e6df04e256ad8938aefd7ee39e0d57b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 08:41:21 +0000 Subject: [PATCH 1/6] feat(spec): ApiRoutes declares the `datasources` direct-mount surface key (#6633 stage 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base for the `datasources/:name/external/*` federation-admin family (ADR-0015 §6.2). Optional like `mcp`: absent = not mounted (ADR-0076 D12). `packages` already existed; only `datasources` is new. Generated artifacts (authorable-surface/api.json, references/api/discovery.mdx) regenerated via check:generated --fix. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --- content/docs/references/api/discovery.mdx | 1 + packages/spec/authorable-surface/api.json | 1 + packages/spec/src/api/discovery.test.ts | 22 ++++++++++++++++++ packages/spec/src/api/discovery.zod.ts | 28 +++++++++++++++++++++++ 4 files changed, 52 insertions(+) diff --git a/content/docs/references/api/discovery.mdx b/content/docs/references/api/discovery.mdx index fbb731f391..c3c76f6918 100644 --- a/content/docs/references/api/discovery.mdx +++ b/content/docs/references/api/discovery.mdx @@ -46,6 +46,7 @@ const result = ApiRoutesSchema.parse(data); | **storage** | `string` | optional | e.g. /api/v1/storage | | **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 | | **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 0d5c357a9e..0ee78a94b7 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -144,6 +144,7 @@ "api/ApiRoutes:auth", "api/ApiRoutes:automation", "api/ApiRoutes:data", + "api/ApiRoutes:datasources", "api/ApiRoutes:discovery", "api/ApiRoutes:i18n", "api/ApiRoutes:mcp", diff --git a/packages/spec/src/api/discovery.test.ts b/packages/spec/src/api/discovery.test.ts index cb9b1e147d..397d887172 100644 --- a/packages/spec/src/api/discovery.test.ts +++ b/packages/spec/src/api/discovery.test.ts @@ -91,6 +91,28 @@ describe('ApiRoutesSchema', () => { expect(routes.data).toBe('/api/v1/data'); expect(routes.auth).toBeUndefined(); }); + + // [#6633] The two direct-mount surface keys. `packages` predates this issue; + // `datasources` is the base for the `datasources/:name/external/*` + // federation-admin family. Both optional: absent = not mounted (ADR-0076 + // D12), and a rebased deployment advertises its real base. + it('accepts the direct-mount surface keys (packages / datasources), rebased or absent', () => { + const rebased = ApiRoutesSchema.parse({ + data: '/backend/api/v9/data', + metadata: '/backend/api/v9/meta', + packages: '/backend/api/v9/packages', + datasources: '/backend/api/v9/datasources', + }); + expect(rebased.packages).toBe('/backend/api/v9/packages'); + expect(rebased.datasources).toBe('/backend/api/v9/datasources'); + + const minimal = ApiRoutesSchema.parse({ + data: '/api/v1/data', + metadata: '/api/v1/meta', + }); + expect(minimal.packages).toBeUndefined(); + expect(minimal.datasources).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 6a000b6cb0..22602d4047 100644 --- a/packages/spec/src/api/discovery.zod.ts +++ b/packages/spec/src/api/discovery.zod.ts @@ -182,6 +182,34 @@ export const ApiRoutesSchema = lazySchema(() => z.object({ /** Base URL for Package Management */ packages: z.string().optional().describe('e.g. /api/v1/packages'), + /** + * Base URL for the datasource federation-admin family — the base under + * which the external-datasource routes (`{datasources}/:name/external/*`, + * ADR-0015 §6.2: tables / draft / import / refresh-catalog / validate) are + * mounted. + * + * Declared by #6633 (route B toward #6306): the SDK's + * `datasources.external.*` methods hard-coded `/api/v1/datasources` with no + * discovery mechanism at all, so any deployment on a non-default base + * (`apiPath`, or a programmatic `basePath`/`version`) had the whole family + * pinned to a convention the server had moved away from. With the key + * declared, a host that mounts the family advertises WHERE, and the SDK + * follows — falling back to the `/api/v1/datasources` convention when + * unadvertised. + * + * `optional`, not `nullable` — same reasoning as `mcp`: the key is ABSENT + * when no federation surface is mounted. The runtime dispatcher serves no + * `/datasources` 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 derives the value from its recorded direct mounts. + */ + datasources: z.string().optional().describe( + 'e.g. /api/v1/datasources — base for the datasources/:name/external/* federation-admin family; ' + + '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 37c1bd1178d8b71050b0ae342e77d6271c91b3bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 08:43:23 +0000 Subject: [PATCH 2/6] feat(metadata-protocol): getDiscovery() advertises routes.packages iff the package service is registered (#6633 stage 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit serviceToRouteKey gains `package: 'packages'`; the route flows through a NON_SLOT_SERVICE_ROUTES loop because `package` is not a CoreServiceName slot (a non-slot SERVICE_CONFIG row is the retired graphql defect and would fabricate a services entry). `datasources` deliberately stays un-advertised here — same disposition as mcp (#5679): the federation mount belongs to the REST host this builder cannot see. Conformance tests pin both directions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --- .../src/discovery-schema-conformance.test.ts | 35 +++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 38 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/packages/metadata-protocol/src/discovery-schema-conformance.test.ts b/packages/metadata-protocol/src/discovery-schema-conformance.test.ts index 687c519b74..f9e7e92c1c 100644 --- a/packages/metadata-protocol/src/discovery-schema-conformance.test.ts +++ b/packages/metadata-protocol/src/discovery-schema-conformance.test.ts @@ -124,6 +124,41 @@ describe('[#4828] getDiscovery() conforms to DiscoverySchema', () => { expect(declaredRouteKeys().has('mcp')).toBe(true); }); + // ═════════════════════════════════════════════════════════════════════════ + // [#6633] The direct-mount surface keys: `packages` flows, `datasources` + // deliberately does not + // ═════════════════════════════════════════════════════════════════════════ + describe('[#6633] direct-mount surface keys', () => { + it('advertises `routes.packages` iff the `package` service is registered', async () => { + // Registered — the same predicate that gates the @objectstack/rest + // direct-mount registrar (`direct-mount-composition.ts`), so on the host + // that serves this builder's shape, advertised ⇔ mounted. + const withPackage: any = await makeImpl(new Map([['package', {}]])).getDiscovery(); + expect(withPackage.routes.packages).toBe('/api/v1/packages'); + + // Absent — nothing mounts the surface for this boot, so the key is + // absent, never a promise of a 404 (ADR-0076 D12). + const without: any = await makeImpl().getDiscovery(); + expect(Object.prototype.hasOwnProperty.call(without.routes, 'packages')).toBe(false); + }); + + it('does NOT advertise `datasources` — this builder knows nothing about the federation mount', async () => { + // Same disposition as `mcp` above: the `datasources/:name/external/*` + // family is mounted by the REST host (unconditionally, 503-degrading), + // which this builder cannot see — and the runtime dispatcher serves no + // /datasources domain at all. Even a registered `external-datasource` + // service says nothing about an HTTP mount, so advertising here would be + // the advertise-the-unmounted half of D12. The REST discovery endpoint + // advertises it from its recorded direct mounts. + const discovery: any = await makeImpl( + new Map([['external-datasource', {}]]), + ).getDiscovery(); + + expect(Object.prototype.hasOwnProperty.call(discovery.routes, 'datasources')).toBe(false); + expect(declaredRouteKeys().has('datasources')).toBe(true); + }); + }); + // ═════════════════════════════════════════════════════════════════════════ // [#5672] Fullness: the vocabulary, whole, from every producer // ═════════════════════════════════════════════════════════════════════════ diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index ad818cbe5f..1f0e0ac0db 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2775,6 +2775,34 @@ export class ObjectStackProtocolImplementation implements ai: 'ai', i18n: 'i18n', 'file-storage': 'storage', + // [#6633] The package-management surface. `package` is NOT a + // CoreServiceName slot, so it must not enter SERVICE_CONFIG — a + // non-slot row there is the shape of the retired `graphql` defect, + // and it would also fabricate a `services` availability entry whose + // remedy line lies. Its route flows through the + // NON_SLOT_SERVICE_ROUTES loop below instead: same gate + // (registered service), same mapping table, one hop over. + package: 'packages', + }; + + // [#6633] Routes advertised for registered services that are not + // CoreServiceName slots. Advertised iff the service is registered — + // the same convention every SERVICE_CONFIG row uses, and for `package` + // it is exactly the predicate that decides the mount on both real host + // types: the @objectstack/rest direct-mount registrar is gated on this + // same service (`direct-mount-composition.ts`), and the runtime + // dispatcher — whose `/packages` domain is unconditional — answers + // discovery from its own `getDiscoveryInfo()`, never from this + // builder. + // + // `datasources` is deliberately NOT here (same reasoning as `mcp`, + // #5679): the federation mount belongs to the REST host, which this + // builder cannot see, and the runtime dispatcher serves no + // `/datasources` domain at all — advertising it from here would be the + // advertise-the-unmounted half of ADR-0076 D12. The REST discovery + // endpoint advertises it from its recorded direct mounts. + const NON_SLOT_SERVICE_ROUTES: Record = { + package: '/api/v1/packages', }; const optionalRoutes: Partial = {}; @@ -2791,6 +2819,16 @@ export class ObjectStackProtocolImplementation implements } } + // [#6633] Same flow for the non-slot routed services declared above. + for (const [serviceName, route] of Object.entries(NON_SLOT_SERVICE_ROUTES)) { + if (registeredServices.has(serviceName)) { + const routeKey = serviceToRouteKey[serviceName]; + if (routeKey) { + optionalRoutes[routeKey] = route; + } + } + } + const routes: ApiRoutes = { data: '/api/v1/data', metadata: '/api/v1/meta', From 076f8365b0760dc205f063bfc823bf3d4aca5867 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 08:48:31 +0000 Subject: [PATCH 3/6] feat(rest): /discovery advertises routes.packages + routes.datasources as projections of the recorded direct mounts (#6633 stage 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mounted => advertised (ADR-0076 D12's unstated half): RestServer. getDirectMountRouteBases() derives the advertised bases from the very route arrays the direct-mount registrars iterated to mount (#5822) — one fact, two consumers, so #6306's future mount-base move carries the advertisement with it by construction. Not mounted => not advertised: a boot without the package service deletes the protocol's optimistic packages entry. End-to-end parity pin drives the composed surface at both /api/v1 and a non-default base, plus the scoped mount. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --- ...ry-advertised-direct-mounts.parity.test.ts | 226 ++++++++++++++++++ packages/rest/src/rest-server.ts | 78 ++++++ 2 files changed, 304 insertions(+) create mode 100644 packages/rest/src/discovery-advertised-direct-mounts.parity.test.ts diff --git a/packages/rest/src/discovery-advertised-direct-mounts.parity.test.ts b/packages/rest/src/discovery-advertised-direct-mounts.parity.test.ts new file mode 100644 index 0000000000..1cbae8b910 --- /dev/null +++ b/packages/rest/src/discovery-advertised-direct-mounts.parity.test.ts @@ -0,0 +1,226 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#6633] The mounted ⇒ advertised parity pin for the direct-mount surfaces +// (ADR-0076 D12, the half its text does not spell out: advertise everything +// mounted — the text bans advertising the unmounted). +// +// What is pinned, end to end: boot the REST surface the way production boots +// it — `RestServer.registerRoutes()` + `mountAndRecordDirectRoutes(...)`, the +// SAME composition `rest-api-plugin.ts` runs — over a recording host server +// whose handler table is the real mounted surface. Then read `/discovery` +// through that table and assert the advertised `routes.packages` / +// `routes.datasources` URLs ANSWER through the same table. +// +// Why this shape: the advertisement is a projection of the recorded mounts +// (`RestServer.getDirectMountRouteBases`), and the mounts are the arrays the +// registrars iterated to mount (#5822). This test holds the two ends of that +// chain together against the live surface, so ANY future change that moves +// only one side — including #6306 moving the direct-mount base onto +// `apiPath` — goes red here: move the mount without the advertisement (or +// vice versa) and the advertised URL stops resolving in the handler table. +// +// The non-default-base case is the load-bearing one. Today the direct-mount +// registrars mount at the PLUGIN's `versionedBase`, not at the RestServer's +// `getApiBasePath()` — the two disagree exactly when `apiPath` is set (#6306's +// subject). Advertising from the recorded mounts keeps `/discovery` honest on +// both sides of that move, and this file measures it at a base that is not +// `/api/v1` to prove nothing re-derives the convention. + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server.js'; +import { mountAndRecordDirectRoutes } from './direct-mount-composition.js'; + +type Handler = (req: any, res: any) => any; + +/** + * A host server whose registrations land in a REAL handler table — both the + * RouteManager registrations (`RestServer` mounts through it synchronously) + * and the direct-mount registrars' rows, so one table answers "what is + * mounted" for the whole boot. + */ +function createRecordingServer() { + const table = new Map(); + const on = (method: string) => (path: string, handler: Handler) => { + table.set(`${method} ${path}`, handler); + }; + const server = { + get: on('GET'), post: on('POST'), put: on('PUT'), delete: on('DELETE'), patch: on('PATCH'), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; + return { server, table }; +} + +/** Match a concrete URL path against the table's `:param` patterns. */ +function resolveRoute(table: Map, method: string, url: string): + { handler: Handler; params: Record } | undefined { + const urlSegs = url.split('/'); + for (const [key, handler] of table) { + const [m, pattern] = key.split(' '); + if (m !== method) continue; + const patSegs = pattern.split('/'); + if (patSegs.length !== urlSegs.length) continue; + const params: Record = {}; + let ok = true; + for (let i = 0; i < patSegs.length; i++) { + if (patSegs[i].startsWith(':')) params[patSegs[i].slice(1)] = urlSegs[i]; + else if (patSegs[i] !== urlSegs[i]) { ok = false; break; } + } + if (ok) return { handler, params }; + } + return undefined; +} + +/** Drive one mounted handler the way an adapter would, capturing the body. */ +async function drive(entry: { handler: Handler; params: Record }, query: Record = {}) { + let body: any; + let statusCode = 200; + const res: any = { + status: (c: number) => { statusCode = c; return res; }, + json: (b: any) => { body = b; }, + }; + await entry.handler({ params: entry.params, query, body: {} }, res); + return { statusCode, body }; +} + +/** + * Boot the real composition. `withPackageService` gates the package registrar + * exactly the way production is gated (the `package` kernel service); + * `externalService` is what the federation routes resolve per request. + */ +function boot(opts: { + versionedBase: string; + withPackageService?: boolean; + enableProjectScoping?: boolean; + projectResolution?: string; +}) { + const { server, table } = createRecordingServer(); + const engine = { + registry: { getObject: (_n: string) => undefined, getRegisteredTypes: () => [] }, + }; + const services = new Map( + opts.withPackageService === false ? [] : [['package', { list: async () => [] }]], + ); + const protocol = new ObjectStackProtocolImplementation(engine as any, () => services); + const config: any = { + api: { + ...(opts.enableProjectScoping + ? { enableProjectScoping: true, projectResolution: opts.projectResolution ?? 'auto' } + : {}), + }, + }; + const rest = new RestServer(server as any, protocol as any, config); + rest.registerRoutes(); + const ctx = { + getService: (name: string) => { + if (name === 'package' && opts.withPackageService !== false) return { list: async () => [] }; + if (name === 'external-datasource') { + return { listRemoteTables: async (_n: string, _o: any) => [{ name: 'customers' }] }; + } + return undefined; + }, + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + mountAndRecordDirectRoutes({ + server: server as any, + recorder: rest, + ctx: ctx as any, + versionedBase: opts.versionedBase, + enableProjectScoping: opts.enableProjectScoping, + projectResolution: opts.projectResolution, + }); + return { rest, table }; +} + +async function readDiscovery(table: Map, base = '/api/v1', params: Record = {}) { + const entry = resolveRoute(table, 'GET', `${base}/discovery`.replace(/:environmentId/g, params.environmentId ?? ':environmentId')); + expect(entry, `GET ${base}/discovery must be mounted`).toBeDefined(); + const { body } = await drive({ handler: entry!.handler, params }); + return body; +} + +describe('[#6633] /discovery advertises the direct-mount surfaces where they are ACTUALLY mounted', () => { + it('advertised routes.packages / routes.datasources resolve and ANSWER in the mounted table (default base)', async () => { + const { table } = boot({ versionedBase: '/api/v1' }); + const discovery = await readDiscovery(table); + + // Advertised… + expect(discovery.routes.packages).toBe('/api/v1/packages'); + expect(discovery.routes.datasources).toBe('/api/v1/datasources'); + + // …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(); + const pkgAnswer = await drive(pkg!); + expect(pkgAnswer.statusCode).toBe(200); + expect(pkgAnswer.body?.success).toBe(true); + + const ext = resolveRoute(table, 'GET', `${discovery.routes.datasources}/pg_main/external/tables`); + expect(ext, 'advertised routes.datasources must be the base of the mounted federation family').toBeDefined(); + const extAnswer = await drive(ext!); + expect(extAnswer.statusCode).toBe(200); + expect(extAnswer.body?.data?.tables).toEqual([{ name: 'customers' }]); + }); + + it('follows a NON-default mount base — the advertisement derives from the mounts, never from the /api/v1 convention', async () => { + // Today this split is real: the direct-mount registrars mount at the + // plugin's `versionedBase` while /discovery itself serves from the + // RestServer's own base. When #6306 moves the mount base, this case is + // what keeps advertisement and mount inseparable. + const { table } = boot({ versionedBase: '/backend/api/v9' }); + const discovery = await readDiscovery(table); + + expect(discovery.routes.packages).toBe('/backend/api/v9/packages'); + expect(discovery.routes.datasources).toBe('/backend/api/v9/datasources'); + + const pkg = resolveRoute(table, 'GET', discovery.routes.packages); + expect(pkg).toBeDefined(); + expect((await drive(pkg!)).body?.success).toBe(true); + + const ext = resolveRoute(table, 'GET', `${discovery.routes.datasources}/pg_main/external/tables`); + expect(ext).toBeDefined(); + expect((await drive(ext!)).body?.data?.tables).toEqual([{ name: 'customers' }]); + + // The convention paths are NOT mounted on this boot, so advertising them + // 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(); + }); + + it('not mounted ⇒ not advertised: a boot without the package service advertises no routes.packages', async () => { + const { table } = boot({ versionedBase: '/api/v1', withPackageService: false }); + const discovery = await readDiscovery(table); + + // The registrar was never called (same gate as production), so nothing is + // recorded and nothing is advertised — D12's other half, kept honest even + // though the PROTOCOL half would happily stay silent too (no `package` + // service in its registry either; the override is what guarantees it). + expect(Object.prototype.hasOwnProperty.call(discovery.routes, 'packages')).toBe(false); + expect(resolveRoute(table, 'GET', '/api/v1/packages')).toBeUndefined(); + + // The federation family mounts unconditionally (degrades per request), so + // 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(); + }); + + it('scoped discovery advertises the scoped packages mount with the environment id substituted', async () => { + const { table } = boot({ versionedBase: '/api/v1', enableProjectScoping: true, projectResolution: 'auto' }); + + const discovery = await readDiscovery(table, '/api/v1/environments/:environmentId', { environmentId: 'env_alpha' }); + expect(discovery.routes.packages).toBe('/api/v1/environments/env_alpha/packages'); + // The scoped variant is genuinely mounted (`auto` mirrors the package + // routes under both bases) and the advertised URL resolves against it. + expect(resolveRoute(table, 'GET', '/api/v1/environments/env_alpha/packages')).toBeDefined(); + + // The federation family has no scoped variant — the unscoped mount is the + // truth, and the scoped response says so rather than inventing one. + expect(discovery.routes.datasources).toBe('/api/v1/datasources'); + + // The unscoped response keeps the unscoped mount. + const unscoped = await readDiscovery(table); + expect(unscoped.routes.packages).toBe('/api/v1/packages'); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 302a3f9c8d..55e1291a5a 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3383,6 +3383,33 @@ export class RestServer { : basePath; discovery.routes.auth = `${unscopedBase}/auth`; } + + // [#6633] Direct-mount surfaces — the mounted ⇒ advertised + // half of ADR-0076 D12. `routes.packages` and + // `routes.datasources` are PROJECTIONS of the recorded + // direct mounts (#5822): the advertised base is read off + // the very route arrays the registrars iterated to mount, + // so advertisement and mounting derive from one fact and + // cannot drift. Today those registrars mount at the + // plugin's `versionedBase` (`/api/v1`) — NOT at this + // server's `getApiBasePath()` — and the advertisement + // says so; when #6306 moves the mount base, the recorded + // paths move and this advertisement follows by + // construction, with no edit here. + // + // A boot that mounted nothing (no `package` service ⇒ + // the registrar was never called) advertises nothing: + // the protocol's service-presence `packages` entry is + // deleted rather than left to promise a 404 — this + // server knows the mount fact, which is strictly better + // knowledge than service presence. + const direct = this.getDirectMountRouteBases( + isScoped ? (req.params?.environmentId ?? ':environmentId') : undefined, + ); + if (direct.packages) discovery.routes.packages = direct.packages; + else delete discovery.routes.packages; + if (direct.datasources) discovery.routes.datasources = direct.datasources; + else delete discovery.routes.datasources; } // Cross-object atomic batch capability (#3298). `declared === @@ -9156,6 +9183,57 @@ export class RestServer { } } + /** + * [#6633] The advertised bases for the direct-mount surfaces, derived from + * the RECORDED mounts themselves — never recomputed from config. + * + * This is the load-bearing half of the mounted ⇒ advertised parity + * (ADR-0076 D12): the registrars mount at whatever base the plugin threads + * in (`versionedBase` today; #6306 will move it), the recorder keeps the + * very arrays they iterated to mount (#5822), and this method projects the + * advertised `routes.packages` / `routes.datasources` out of those arrays. + * One expression, two consumers — a future change that moves the 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. + */ + getDirectMountRouteBases(scopedEnvironmentId?: string): { packages?: string; datasources?: string } { + const SCOPED_SEGMENT = '/environments/:environmentId'; + let packagesUnscoped: string | undefined; + let packagesScoped: string | undefined; + let datasources: string | undefined; + for (const { method, path } of this.directMountedRoutes) { + // The package registrar's list route (`GET {base}/packages`) IS the + // surface base — recorded verbatim, recognised, never rebuilt. + if (method === 'GET' && path.endsWith('/packages')) { + if (path.includes(SCOPED_SEGMENT)) packagesScoped = path; + else packagesUnscoped = path; + } + // Every federation route sits under + // `{base}/datasources/:name/external/…`; the advertised base is + // `{base}/datasources`. + const extAt = path.indexOf('/datasources/:name/external/'); + if (extAt >= 0 && datasources === undefined) { + datasources = `${path.slice(0, extAt)}/datasources`; + } + } + // A scoped discovery response advertises the scoped packages mount when + // one is recorded (with the caller's environment id substituted, the + // same move the `data`/`metadata` overrides make); the unscoped mount + // is the answer everywhere else. No cross-over in the unscoped case: + // advertising a `:environmentId` pattern to an unscoped caller would be + // a URL nothing can consume. + const packages = scopedEnvironmentId !== undefined + ? (packagesScoped?.replace(':environmentId', scopedEnvironmentId) ?? packagesUnscoped) + : packagesUnscoped; + return { packages, datasources }; + } + /** * Get all routes mounted for this boot — the whole surface this server * knows about, RouteManager's table and the recorded direct mounts alike. From aff5f3c94cef50e1485a6e000983257b6d2c6968 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 08:50:10 +0000 Subject: [PATCH 4/6] fix(client): datasources.external.* derives its base from discovered routes.datasources (#6633 stage 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five federation-admin methods go through getRoute('datasources'): connected clients follow the advertised base, unconnected (or unadvertising servers) fall back to the /api/v1/datasources convention byte-identically. routeMap stays total over the declared ApiRoutes keys. Cases B and C from the issue pinned as tests — C asserts all five external.* URLs plus packages.* in one case so a half-fix cannot stay green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --- packages/client/src/client.test.ts | 49 ++++++++++++++++++++++++++++++ packages/client/src/index.ts | 30 +++++++++++++++--- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 7ad45f554c..57a51d3bb3 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -509,6 +509,55 @@ describe('Data actions, email, dataset query, external datasources (#3587 gap cl ); for (let i = 1; i <= 4; i++) expect(fetchMock.mock.calls[i][1].method).toBe('POST'); }); + + // [#6633] The three probe cases from the issue. Case A is the pin test + // above (unconnected ⇒ the `/api/v1` convention, byte-identical to the + // pre-#6633 hardcode). B and C cover the discovery-following half. + it('[#6633] discovery WITHOUT packages/datasources keys leaves the convention untouched (case B)', async () => { + const { client, fetchMock } = createMockClient({ tables: [] }); + // A server rebased to /backend/api/v9 that does not advertise the two + // direct-mount keys — exactly what a pre-#6633 rest surface answers. + (client as any)['discoveryInfo'] = { + routes: { data: '/backend/api/v9/data', metadata: '/backend/api/v9/meta', ui: '/backend/api/v9/ui' }, + }; + await client.packages.list(); + expect(String(fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/packages'); + await client.datasources.external.listTables('pg_main'); + expect(String(fetchMock.mock.calls[1][0])).toBe( + 'http://localhost:3000/api/v1/datasources/pg_main/external/tables', + ); + }); + + it('[#6633] BOTH packages.* and all five external.* follow advertised rebased routes (case C)', async () => { + const { client, fetchMock } = createMockClient({ tables: [] }); + (client as any)['discoveryInfo'] = { + routes: { + data: '/backend/api/v9/data', + metadata: '/backend/api/v9/meta', + packages: '/backend/api/v9/packages', + datasources: '/backend/api/v9/datasources', + }, + }; + + // packages.* — the mechanism that already existed, kept following. + await client.packages.list(); + expect(String(fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/backend/api/v9/packages'); + + // external.* — the half that ignored discovery entirely before #6633. + // All five in ONE case so a half-fix (some methods still hard-coded) + // cannot stay green. + const base = 'http://localhost:3000/backend/api/v9/datasources/pg_main/external'; + await client.datasources.external.listTables('pg_main', { schema: 'public' }); + expect(String(fetchMock.mock.calls[1][0])).toBe(`${base}/tables?schema=public`); + await client.datasources.external.draft('pg_main', 'customers'); + expect(String(fetchMock.mock.calls[2][0])).toBe(`${base}/tables/customers/draft`); + await client.datasources.external.import('pg_main', 'customers'); + expect(String(fetchMock.mock.calls[3][0])).toBe(`${base}/tables/customers/import`); + await client.datasources.external.refreshCatalog('pg_main'); + expect(String(fetchMock.mock.calls[4][0])).toBe(`${base}/refresh-catalog`); + await client.datasources.external.validate('pg_main'); + expect(String(fetchMock.mock.calls[5][0])).toBe(`${base}/validate`); + }); }); describe('Approvals namespace (ADR-0019)', () => { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 3b4ded6e94..dc05d13ad2 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1060,22 +1060,31 @@ export class ObjectStackClient { * catalog and importing tables as federated objects. 503 * [external_service_unavailable] without the `external-datasource` * service. (#3587 gap closure) + * + * [#6633] The family base comes from `getRoute('datasources')`: a connected + * client follows the server's advertised `routes.datasources` (the REST + * discovery endpoint derives it from its recorded mounts, ADR-0076 D12); + * an unconnected client — or one talking to a server that advertises no + * `datasources` key — falls back to the `/api/v1/datasources` convention, + * byte-identical to the pre-#6633 hardcode. */ datasources = { external: { /** List remote tables on a datasource, optionally by `schema`. */ listTables: async (name: string, opts?: { schema?: string }): Promise => { const qs = opts?.schema ? `?schema=${encodeURIComponent(opts.schema)}` : ''; + const route = this.getRoute('datasources'); const res = await this.fetch( - `${this.baseUrl}/api/v1/datasources/${encodeURIComponent(name)}/external/tables${qs}`, + `${this.baseUrl}${route}/${encodeURIComponent(name)}/external/tables${qs}`, ); return this.unwrapResponse(res); }, /** Generate an Object draft (structured + `*.object.ts` source) from a remote table. */ draft: async (name: string, remoteTable: string, opts?: Record): Promise => { + const route = this.getRoute('datasources'); const res = await this.fetch( - `${this.baseUrl}/api/v1/datasources/${encodeURIComponent(name)}/external/tables/${encodeURIComponent(remoteTable)}/draft`, + `${this.baseUrl}${route}/${encodeURIComponent(name)}/external/tables/${encodeURIComponent(remoteTable)}/draft`, { method: 'POST', body: JSON.stringify(opts ?? {}) }, ); return this.unwrapResponse(res); @@ -1086,8 +1095,9 @@ export class ObjectStackClient { * Object"). 400 [external_import_error] when refused. */ import: async (name: string, remoteTable: string, opts?: Record): Promise => { + const route = this.getRoute('datasources'); const res = await this.fetch( - `${this.baseUrl}/api/v1/datasources/${encodeURIComponent(name)}/external/tables/${encodeURIComponent(remoteTable)}/import`, + `${this.baseUrl}${route}/${encodeURIComponent(name)}/external/tables/${encodeURIComponent(remoteTable)}/import`, { method: 'POST', body: JSON.stringify(opts ?? {}) }, ); return this.unwrapResponse(res); @@ -1095,8 +1105,9 @@ export class ObjectStackClient { /** Refresh and return the cached remote-catalog snapshot. */ refreshCatalog: async (name: string): Promise => { + const route = this.getRoute('datasources'); const res = await this.fetch( - `${this.baseUrl}/api/v1/datasources/${encodeURIComponent(name)}/external/refresh-catalog`, + `${this.baseUrl}${route}/${encodeURIComponent(name)}/external/refresh-catalog`, { method: 'POST', body: JSON.stringify({}) }, ); return this.unwrapResponse(res); @@ -1104,8 +1115,9 @@ export class ObjectStackClient { /** Validate this datasource's federated objects against the remote schema. */ validate: async (name: string): Promise => { + const route = this.getRoute('datasources'); const res = await this.fetch( - `${this.baseUrl}/api/v1/datasources/${encodeURIComponent(name)}/external/validate`, + `${this.baseUrl}${route}/${encodeURIComponent(name)}/external/validate`, { method: 'POST', body: JSON.stringify({}) }, ); return this.unwrapResponse(res); @@ -4792,6 +4804,14 @@ export class ObjectStackClient { // which suits `mcp` exactly: `/mcp` is mounted bare, so even a // project-scoped discovery response advertises the unscoped path. mcp: '/api/v1/mcp', + // [#6633] `datasources` became a declared `ApiRoutes` key (the base of + // the `datasources/:name/external/*` federation-admin family), and this + // map is TOTAL over declared keys by design. `/api/v1/datasources` is + // not a guess: it is where `@objectstack/rest` mounts the family today, + // so an unconnected client builds byte-identical URLs to the pre-#6633 + // hardcode — the fallback agrees with the mount instead of competing + // with it. + datasources: '/api/v1/datasources', }; return routeMap[type] || `/api/v1/${type}`; From b22b5399adf43dd929a08635c4d1a1f317327f69 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 08:50:36 +0000 Subject: [PATCH 5/6] chore: changeset for the #6633 four-package discovery/SDK change Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --- .../discovery-direct-mount-route-keys.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .changeset/discovery-direct-mount-route-keys.md diff --git a/.changeset/discovery-direct-mount-route-keys.md b/.changeset/discovery-direct-mount-route-keys.md new file mode 100644 index 0000000000..79bf6bc9ed --- /dev/null +++ b/.changeset/discovery-direct-mount-route-keys.md @@ -0,0 +1,41 @@ +--- +"@objectstack/spec": minor +"@objectstack/metadata-protocol": minor +"@objectstack/rest": minor +"@objectstack/client": patch +--- + +feat(spec,metadata-protocol,rest,client): the direct-mount surfaces (`packages`, `datasources/:name/external/*`) become discoverable, and the SDK follows the advertised base (#6633) + +The rest surface's `/discovery` never advertised `routes.packages` — routes +mounted but not advertised, the unstated half of ADR-0076 D12 — so the SDK's +`packages.*` always fell back to the hard-coded `/api/v1/packages`; and the +SDK's `datasources.external.*` had no discovery mechanism at all, hard-coding +`/api/v1/datasources/...` in each of its five methods. On any deployment with a +non-default API base, both families built wrong URLs (measured in #6633). +Maintainer ruling 2026-08-08 (route B, prerequisite for #6306): + +- **spec** (minor, additive): `ApiRoutesSchema` declares a `datasources` key — + the base of the federation-admin family. Optional like `mcp`: absent = not + mounted. +- **metadata-protocol** (minor, additive): `getDiscovery()` advertises + `routes.packages: '/api/v1/packages'` iff the `package` service is + registered (`serviceToRouteKey` gains the mapping; the route flows through a + non-slot table because `package` is not a `CoreServiceName`). `datasources` + is deliberately NOT advertised by this builder — the mount belongs to the + REST host it cannot see (same disposition as `mcp`). +- **rest** (minor): `/discovery` advertises `routes.packages` and + `routes.datasources` as projections of the RECORDED direct mounts (#5822) — + advertisement and mounting derive from one fact, so #6306's later mount-base + move carries the advertisement along by construction. Not mounted ⇒ not + advertised. An end-to-end parity pin (`discovery-advertised-direct-mounts. + parity.test.ts`) drives the composed surface and goes red on any change that + moves only one side. +- **client** (patch, behavior fix): the five `datasources.external.*` methods + derive their base via `getRoute('datasources')` — connected clients follow + the advertised base; unconnected clients (or servers that advertise no + `datasources` key) keep building byte-identical `/api/v1/...` URLs. + +No key is removed and no wire shape changes for existing deployments: servers +gain two advertised keys, and the SDK changes URLs only when a server +advertises the new keys with a non-default base. From 46e82d44d5b76cd370e0f5c01ca19d478f07dbca Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 12:32:50 +0000 Subject: [PATCH 6/6] chore(spec): regenerate authorable-surface/api.json after merging origin/main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated artifact text-merged (AGENTS.md §11): this branch's older copy won the hunk main had grown (GetMetaItemLayeredResponse / GetMetaItemResponse keys). Rebuilt from the MERGED source, so the artifact now carries both sides and the branch delta vs main is exactly the one intended `api/ApiRoutes:datasources` line. check:generated: all 10 up to date. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --- packages/spec/authorable-surface/api.json | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 0ee78a94b7..49d16b75f7 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -712,11 +712,38 @@ "api/GetMetaItemCachedResponse:lastModified", "api/GetMetaItemCachedResponse:notModified", "api/GetMetaItemCachedResponse:version", + "api/GetMetaItemLayeredResponse:_diagnostics", + "api/GetMetaItemLayeredResponse:code", + "api/GetMetaItemLayeredResponse:deletable", + "api/GetMetaItemLayeredResponse:editable", + "api/GetMetaItemLayeredResponse:effective", + "api/GetMetaItemLayeredResponse:lock", + "api/GetMetaItemLayeredResponse:lockDocsUrl", + "api/GetMetaItemLayeredResponse:lockReason", + "api/GetMetaItemLayeredResponse:lockSource", + "api/GetMetaItemLayeredResponse:name", + "api/GetMetaItemLayeredResponse:overlay", + "api/GetMetaItemLayeredResponse:overlayScope", + "api/GetMetaItemLayeredResponse:packageId", + "api/GetMetaItemLayeredResponse:packageVersion", + "api/GetMetaItemLayeredResponse:provenance", + "api/GetMetaItemLayeredResponse:resettable", + "api/GetMetaItemLayeredResponse:type", "api/GetMetaItemRequest:name", "api/GetMetaItemRequest:packageId", "api/GetMetaItemRequest:type", + "api/GetMetaItemResponse:deletable", + "api/GetMetaItemResponse:editable", "api/GetMetaItemResponse:item", + "api/GetMetaItemResponse:lock", + "api/GetMetaItemResponse:lockDocsUrl", + "api/GetMetaItemResponse:lockReason", + "api/GetMetaItemResponse:lockSource", "api/GetMetaItemResponse:name", + "api/GetMetaItemResponse:packageId", + "api/GetMetaItemResponse:packageVersion", + "api/GetMetaItemResponse:provenance", + "api/GetMetaItemResponse:resettable", "api/GetMetaItemResponse:type", "api/GetMetaItemsRequest:packageId", "api/GetMetaItemsRequest:type",