diff --git a/.changeset/direct-mount-follows-apipath.md b/.changeset/direct-mount-follows-apipath.md new file mode 100644 index 0000000000..1a6de7f1b9 --- /dev/null +++ b/.changeset/direct-mount-follows-apipath.md @@ -0,0 +1,54 @@ +--- +"@objectstack/rest": minor +--- + +fix(rest): 设置了 `api.apiPath` 时,9 条 direct-mount 路由跟随同一个 API base(#6306) + +`RestServer.getApiBasePath()` 回答 `api.apiPath ?? `${basePath}/${version}``, +而 `rest-api-plugin.ts` 为两个 direct-mount registrar(`packages.*` ×4、 +`datasources/:name/external/*` ×5)自行重算了一次 `${basePath}/${version}`, +从不读取 `apiPath`。两个表达式只在 `apiPath` 未设时相等——于是设置了 +`apiPath` 的部署同时出现两个 API 前缀。实测(`apiPath: '/backend/api/v9'`, +真实 `createRestApiPlugin(...).start()` 组合、记录型 host server 枚举全部 +挂载):**92 条路由中 83 条迁到 `{apiPath}`,恰好 9 条滞留 `/api/v1`**; +`{apiPath}/openapi.json` 的 `isUnderBase` 过滤把这 9 条排除在文档之外 +(**71 paths**);`/discovery` 也如实通告了滞留位置 +(`routes.packages: '/api/v1/packages'`)——通告没有说谎,是挂载本身分裂了。 + +按 maintainer 裁定(Option 1,单一真相源):registrar 现在直接消费 +`restServer.getApiBasePath()` 的返回值——共享同一个值,而不是把 `??` +表达式复制到第二处(复制正是这个缺陷的成因)。`getApiBasePath()` 因此 +从 `private` 变为 public,职责写入其 doc comment。 + +**行为变化,仅限设置了 `api.apiPath` 的部署**:这 9 条路由的 URL 从 +`/api/v1/...` 移到 `{apiPath}/...`,旧前缀不再服务(无兼容双挂载)。 +修复后实测 92 条全部挂在 `{apiPath}` 下,`{apiPath}/openapi.json` +完整列出这 9 条(**71 → 79 paths**),`/discovery` 通告 `{apiPath}/packages` +与 `{apiPath}/datasources`。 + +需要动手的只有**基础设施配置**:若反向代理、健康检查或外部监控里硬编码了 +`/api/v1/packages` 或 `/api/v1/datasources/*/external/*`,改成 `{apiPath}/…`。 +**SDK 与应用代码无需改动**:`@objectstack/client` 自 #6633 / PR #6712 起从 +`/discovery` 通告的 base 派生这两个面,而通告是已录制挂载的投影,因此客户端 +按构造跟随本次移动。该键也没有 authoring 路径可达 +(`defineStack({server:{api:…}})` 被 strict 块 loud 拒绝,`api:{apiPath}` 被 +静默 strip,`os serve` 只转发两个 scoping 键),只有程序化组合 +`createRestApiPlugin` 的 embedder 能设到它。 + +**默认配置(未设 `apiPath`)逐字节不变**:两个表达式在该情形下同值;实测 +修复前后默认挂载表(92 条)、`{base}/openapi.json`(79 paths)与 +`/discovery` 通告完全一致,逐行 diff 无差异。 + +另修复同一来源的第二处分歧:插件旧表达式用 `||` 兜底(空串 `basePath` +⇒ `/api`),`RestServer` 规范化用 `??`(空串保留)——`basePath: ''` 时 +route-manager 面挂 `/v1` 而 9 条挂 `/api/v1`,同样的分裂不需要 `apiPath` +也会出现(实测 83/9)。读同一个值后该分歧不复存在。 + +Bump 判定为 `minor` 而非 `patch` / `major`。不是 `patch`:除了修缺陷,它 +改变了一个真实配置键下可观测的 URL 表面,并且新增了公共 API 面 +(`RestServer.getApiBasePath()` 由 `private` 转 public,是这次单一真相源的 +承载物)。不是 `major`:没有任何可授权(authorable)的键被移除或重命名, +没有需要作者迁移的元数据(因而 ADR-0087 无可登记项),默认部署逐字节不变, +受影响部署的客户端按构造跟随;唯一的 FROM → TO 落在部署方自己的代理配置上, +而这些部署今天本就是 split-brain——本次是让 `apiPath` 被完整遵守,不是收回 +一个曾被兑现的承诺。 diff --git a/packages/rest/src/direct-mount-base-follows-apipath.test.ts b/packages/rest/src/direct-mount-base-follows-apipath.test.ts new file mode 100644 index 0000000000..acd9e1c12a --- /dev/null +++ b/packages/rest/src/direct-mount-base-follows-apipath.test.ts @@ -0,0 +1,294 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#6306] ONE API base for the whole REST surface — pinned end to end through +// the plugin that composes it in production. +// +// The defect this replaces: `RestServer.getApiBasePath()` answers +// `api.apiPath ?? `${basePath}/${version}``, but `rest-api-plugin.ts` built +// its own `${basePath}/${version}` for the two direct-mount registrars and +// never read `apiPath`. The two expressions agree only while `apiPath` is +// unset, so a deployment that set it served TWO API prefixes at once — +// measured on `origin/main` @ 11066f681 with `apiPath: '/backend/api/v9'`: +// 92 routes mounted, 83 under `{apiPath}`, and exactly 9 left behind at +// `/api/v1` (`packages.*` ×4, `datasources/:name/external/*` ×5). Those 9 +// were also absent from `{apiPath}/openapi.json` (71 paths vs 79), because +// that document is filtered to this server's base — the filter is what made +// the split visible (#5822 / PR #6303). +// +// What is pinned here, and at which level. This file drives +// `createRestApiPlugin(config).start(ctx)` — the real composition — over a +// recording host server whose handler table IS the mounted surface, then asks +// the three consumers that must agree: where the routes mount, what +// `{base}/openapi.json` documents, and what `{base}/discovery` advertises. +// That plugin-level wiring is deliberately NOT what +// `discovery-advertised-direct-mounts.parity.test.ts` measures: it calls +// `mountAndRecordDirectRoutes` directly with its own `versionedBase`, so it +// pins mounted ⇒ advertised for whatever base it is handed and stays green +// whichever base the plugin picks. The choice of base is this file's subject. +// +// The single-source assertion is the point, not the URLs: each case compares +// the mounted base against `getApiBasePath()` read off an independently +// constructed `RestServer` with the same config. A future edit that +// re-derives the base at the registrars' call site — however correctly — +// fails these, which is the intent: the bug was a second expression, so the +// pin is on there being one. + +// Relative imports carry their `.js` extension (see the note in +// `direct-mount-introspection.test.ts`): under `moduleResolution: nodenext` an +// extension-less one does not resolve and every symbol it names becomes `any`. +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server.js'; +import { createRestApiPlugin } from './rest-api-plugin.js'; +import { REST_ROUTE_LEDGER } from './rest-route-ledger.js'; +import { toTemplatePath } from './openapi-builtin-paths.js'; + +type Handler = (req: any, res: any) => any; + +/** The ledger's own list of the nine, as `VERB {base-relative}` suffixes. */ +const DIRECT_MOUNT_SUFFIXES = REST_ROUTE_LEDGER + .filter((e) => e.source === 'direct-mount') + .map((e) => { + const [method, path] = e.route.split(' '); + return { method, suffix: path.replace(/^\/api\/v1/, '') }; + }); + +/** + * A host server whose registrations land in a real handler table — the + * RouteManager rows `RestServer` mounts and the direct-mount registrars' rows + * alike, so one table answers "what is mounted" for the whole boot. + */ +function createRecordingServer() { + const table = new Map(); + const on = (method: string) => vi.fn((path: string, handler: Handler) => { + table.set(`${method} ${path}`, handler); + }); + const server = { + table, + 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; +} + +/** Match a concrete URL against the table's `:param` patterns. */ +function resolveRoute(table: Map, method: string, url: string) { + 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; +} + +async function drive(entry: { handler: Handler; params: Record }, req: Record = {}) { + let body: any; + let statusCode = 200; + const res: any = { + status: (c: number) => { statusCode = c; return res; }, + json: (b: any) => { body = b; }, + header: () => res, + send: () => {}, + }; + await entry.handler({ params: entry.params, query: {}, body: {}, headers: { host: 'example.test' }, ...req }, res); + return { statusCode, body }; +} + +function makeProtocol() { + const engine = { registry: { getObject: (_n: string) => undefined, getRegisteredTypes: () => [] } }; + const services = new Map([['package', { list: async () => [] }]]); + return new ObjectStackProtocolImplementation(engine as any, () => services); +} + +function createCtx(services: Record) { + return { + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (name in services) return services[name]; + throw new Error(`Service '${name}' not found`); + }), + getServices: vi.fn(() => new Map(Object.entries(services))), + hook: vi.fn(), + trigger: vi.fn().mockResolvedValue(undefined), + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + getKernel: vi.fn(), + }; +} + +/** + * Boot the REST plugin exactly as production does, and report both the + * mounted surface and the base an independently constructed `RestServer` + * computes from the same config — the two things every case compares. + */ +async function bootPlugin(apiConfig?: Record) { + const server = createRecordingServer(); + const ctx = createCtx({ + 'http.server': server, + protocol: makeProtocol(), + package: { list: vi.fn(), get: vi.fn(), publish: vi.fn(), delete: vi.fn() }, + 'external-datasource': { listRemoteTables: async () => [{ name: 'customers' }] }, + }); + await createRestApiPlugin(apiConfig as any).start!(ctx as any); + + // The base the server that owns the surface computes — the single source. + const expectedBase = new RestServer( + createRecordingServer() as any, + makeProtocol() as any, + (apiConfig?.api ?? {}) as any, + ).getApiBasePath(); + + return { server, table: server.table, expectedBase }; +} + +function mountedKeys(table: Map): string[] { + return [...table.keys()]; +} + +async function serveOpenApi(table: Map, base: string) { + const entry = resolveRoute(table, 'GET', `${base}/openapi.json`); + expect(entry, `GET ${base}/openapi.json must be mounted for this pin to mean anything`).toBeDefined(); + const { body } = await drive(entry!, { path: `${base}/openapi.json` }); + return body; +} + +async function readDiscovery(table: Map, base: string) { + const entry = resolveRoute(table, 'GET', `${base}/discovery`); + expect(entry, `GET ${base}/discovery must be mounted`).toBeDefined(); + const { body } = await drive(entry!); + return body; +} + +function documented(doc: any, wirePath: string, method: string): boolean { + return Boolean(doc?.paths?.[toTemplatePath(wirePath)]?.[method.toLowerCase()]); +} + +// --------------------------------------------------------------------------- +// the move — a deployment that sets `apiPath` +// --------------------------------------------------------------------------- + +describe('#6306 — with `apiPath` set, the direct-mount routes follow it', () => { + const API_PATH = '/backend/api/v9'; + const config = { api: { api: { apiPath: API_PATH } } }; + + it('mounts all nine under {apiPath}, and leaves nothing behind at the convention prefix', async () => { + const { table, expectedBase } = await bootPlugin(config); + + // The base is the server's, not a second expression that happens to agree. + expect(expectedBase).toBe(API_PATH); + + for (const { method, suffix } of DIRECT_MOUNT_SUFFIXES) { + expect( + mountedKeys(table), + `${method} ${expectedBase}${suffix} must mount under the one API base`, + ).toContain(`${method} ${expectedBase}${suffix}`); + } + + // The whole surface moved, not merely the nine: no route is left at the + // `/api/v1` convention. This is the split itself — on `origin/main` this + // set had exactly 9 members. + const stragglers = mountedKeys(table).filter((k) => k.split(' ')[1].startsWith('/api/v1')); + expect(stragglers, 'no route may stay at /api/v1 when apiPath moves the surface').toEqual([]); + }); + + it('documents all nine in {apiPath}/openapi.json — the filter that made the split visible now includes them', async () => { + const { table, expectedBase } = await bootPlugin(config); + const doc = await serveOpenApi(table, expectedBase); + + for (const { method, suffix } of DIRECT_MOUNT_SUFFIXES) { + expect( + documented(doc, `${expectedBase}${suffix}`, method), + `${method} ${expectedBase}${suffix} is mounted but not documented`, + ).toBe(true); + } + // …and the stale prefix is documented nowhere, so the document describes + // one surface rather than two. + expect(Object.keys(doc.paths).filter((p) => p.startsWith('/api/v1'))).toEqual([]); + }); + + it('advertises the moved bases in {apiPath}/discovery, and the advertised URLs answer', async () => { + const { table, expectedBase } = await bootPlugin(config); + const discovery = await readDiscovery(table, expectedBase); + + // No edit was needed in the advertising code for this: `routes.packages` / + // `routes.datasources` are projections of the recorded mounts (#6633), so + // moving the mount moved the advertisement. + expect(discovery.routes.packages).toBe(`${expectedBase}/packages`); + expect(discovery.routes.datasources).toBe(`${expectedBase}/datasources`); + + const pkg = resolveRoute(table, 'GET', discovery.routes.packages); + expect(pkg, 'the advertised packages URL must be mounted').toBeDefined(); + expect((await drive(pkg!)).statusCode).toBe(200); + + const ext = resolveRoute(table, 'GET', `${discovery.routes.datasources}/pg_main/external/tables`); + expect(ext, 'the advertised datasources base must be the base of the mounted family').toBeDefined(); + expect((await drive(ext!)).statusCode).toBe(200); + }); +}); + +// --------------------------------------------------------------------------- +// the second divergent expression the single source also collapses +// --------------------------------------------------------------------------- + +describe('#6306 — the base is READ, not rebuilt: `??` and `||` no longer disagree', () => { + it('an empty `basePath` puts the nine where the rest of the surface already was', async () => { + // A second, independent way the two expressions differed: the plugin + // defaulted with `||` (empty string ⇒ `/api`) while `RestServer` + // normalizes with `??` (empty string kept). So `basePath: ''` mounted the + // RouteManager surface at `/v1` and the nine at `/api/v1` — the same + // split, reached without `apiPath` at all. Reading the base cannot + // disagree with itself. + const { table, expectedBase } = await bootPlugin({ api: { api: { basePath: '', version: 'v1' } } }); + expect(expectedBase).toBe('/v1'); + + for (const { method, suffix } of DIRECT_MOUNT_SUFFIXES) { + expect(mountedKeys(table)).toContain(`${method} /v1${suffix}`); + } + expect(mountedKeys(table).filter((k) => k.split(' ')[1].startsWith('/api/v1'))).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// the baseline — unchanged where the two expressions always agreed +// --------------------------------------------------------------------------- + +describe('#6306 — default and conventional configs are unchanged', () => { + // NOTE, honestly: these two cases are green both before and after the fix — + // `apiPath ?? `${basePath}/${version}`` and `${basePath}/${version}` are the + // same string here, which is exactly why the defect hid for so long. They + // are not reverse-verification evidence; they are the regression floor, + // pinning that single-sourcing moved nothing for deployments that never set + // `apiPath` (measured: the default mount list is identical, 92 routes, + // before and after). + it('default config keeps all nine at /api/v1, documented and advertised there', async () => { + const { table, expectedBase } = await bootPlugin(undefined); + expect(expectedBase).toBe('/api/v1'); + + for (const { method, suffix } of DIRECT_MOUNT_SUFFIXES) { + expect(mountedKeys(table)).toContain(`${method} /api/v1${suffix}`); + } + const doc = await serveOpenApi(table, '/api/v1'); + for (const { method, suffix } of DIRECT_MOUNT_SUFFIXES) { + expect(documented(doc, `/api/v1${suffix}`, method)).toBe(true); + } + const discovery = await readDiscovery(table, '/api/v1'); + expect(discovery.routes.packages).toBe('/api/v1/packages'); + expect(discovery.routes.datasources).toBe('/api/v1/datasources'); + }); + + it('a conventional custom basePath/version behaves identically under both expressions', async () => { + const { table, expectedBase } = await bootPlugin({ api: { api: { basePath: '/gateway', version: 'v3' } } }); + expect(expectedBase).toBe('/gateway/v3'); + for (const { method, suffix } of DIRECT_MOUNT_SUFFIXES) { + expect(mountedKeys(table)).toContain(`${method} /gateway/v3${suffix}`); + } + }); +}); 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..2dd6dacf63 100644 --- a/packages/rest/src/discovery-advertised-direct-mounts.parity.test.ts +++ b/packages/rest/src/discovery-advertised-direct-mounts.parity.test.ts @@ -15,16 +15,20 @@ // (`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 +// only one side goes red here: move the mount without the advertisement (or // vice versa) and the advertised URL stops resolving in the handler table. +// #6306 was the first such move and it landed with no edit in this file — +// which is the property working, not the pin missing it. // -// 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. +// The non-default-base case is the load-bearing one: it drives the +// composition at a base that is not `/api/v1` to prove nothing re-derives the +// convention. Note the level this file measures at — it calls +// `mountAndRecordDirectRoutes` directly, so `versionedBase` is its own +// parameter and the projection is pinned independently of WHO chooses that +// base. Since #6306 the production chooser is `RestServer.getApiBasePath()` +// (so `apiPath` deployments mount and advertise under `{apiPath}`); that +// wiring — plugin config in, mounted+advertised+documented URLs out — is +// pinned end to end in `direct-mount-base-follows-apipath.test.ts`. import { describe, it, expect, vi } from 'vitest'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; @@ -165,10 +169,10 @@ describe('[#6633] /discovery advertises the direct-mount surfaces where they are }); 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. + // The mount base is an input here, deliberately decoupled from the + // RestServer's own base: that is what proves the advertisement is read + // off the recorded mounts rather than re-derived from config. It is also + // what kept advertisement and mount inseparable across #6306's move. const { table } = boot({ versionedBase: '/backend/api/v9' }); const discovery = await readDiscovery(table); diff --git a/packages/rest/src/rest-api-plugin-slot-lookups.test.ts b/packages/rest/src/rest-api-plugin-slot-lookups.test.ts index e936ef838b..5a0321aa0d 100644 --- a/packages/rest/src/rest-api-plugin-slot-lookups.test.ts +++ b/packages/rest/src/rest-api-plugin-slot-lookups.test.ts @@ -33,15 +33,27 @@ const captured = vi.hoisted(() => ({ ctorArgs: [] as unknown[][] })); // Capture RestServer's constructor arguments without registering ~hundreds of // routes. Everything else in the module (RestEnvRegistry & co) stays real, so // the plugin's imports resolve normally. +// +// The double EXTENDS the real class rather than replacing it: `registerRoutes` +// is the only expensive thing here, so it is the only thing suppressed, and +// every other method the composition root calls stays the production one. That +// matters beyond tidiness — the plugin also asks the instance for the API base +// (`getApiBasePath()`, #6306), and a hand-written stub that lists only the +// methods the plugin happened to call the day it was written turns each new +// collaborator call into a `TypeError` here, in a file about slot WIRING that +// has no opinion on the base. Inheriting the contract keeps this test measuring +// its own subject. The real constructor is field assignment plus +// `new RouteManager(server)` (a `Map`), so nothing is paid for it. vi.mock('./rest-server.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - RestServer: class { + RestServer: class extends actual.RestServer { constructor(...args: unknown[]) { + super(...(args as ConstructorParameters)); captured.ctorArgs.push(args); } - registerRoutes(): void { + override registerRoutes(): void { /* routes are not under test here */ } }, diff --git a/packages/rest/src/rest-api-plugin.ts b/packages/rest/src/rest-api-plugin.ts index 8160eedfff..54905236dc 100644 --- a/packages/rest/src/rest-api-plugin.ts +++ b/packages/rest/src/rest-api-plugin.ts @@ -379,9 +379,6 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { throw err; } - const basePath = config.api?.api?.basePath || '/api'; - const version = config.api?.api?.version || 'v1'; - const versionedBase = `${basePath}/${version}`; const enableProjectScoping = config.api?.api?.enableProjectScoping ?? false; const projectResolution = config.api?.api?.projectResolution ?? 'auto'; @@ -393,6 +390,19 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { // those are; the route-ledger conformance guard drives the same // function, so a registrar added there cannot slip past it. if (restServer) { + // [#6306] ONE base for the whole surface. This is the same + // value `registerRoutes()` mounted everything else under — + // asked of the server that owns it, never recomputed here. + // The old line was `${basePath}/${version}`, which is + // `getApiBasePath()` minus its `apiPath` branch: a deployment + // setting `api.apiPath` moved 83 routes and left these 9 + // behind at `/api/v1`. Reading the base instead of rebuilding + // it is the whole fix — and it is why the advertisement needs + // no edit: `/discovery`'s `routes.packages` / + // `routes.datasources` are projections of the routes these + // registrars report back (#6633), so the advertisement moves + // with the mount by construction. + const versionedBase = restServer.getApiBasePath(); mountAndRecordDirectRoutes({ server, recorder: restServer, diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index ca5f75d124..b77c62ce70 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3232,9 +3232,24 @@ export class RestServer { } /** - * Get the full API base path + * The full API base path — THE base for this deployment's REST surface. + * + * [#6306] Public because it is the single source of truth, not merely a + * convenience: `rest-api-plugin.ts` threads this very value into the + * direct-mount registrars (`packages.*`, `datasources/:name/external/*`) + * so those nine routes mount under the same prefix as everything the + * RouteManager registers. It used to recompute `${basePath}/${version}` + * for itself, which silently dropped `apiPath` — the two expressions + * agree only while `apiPath` is unset, so a deployment that set it got + * two API prefixes at once: 83 routes under `{apiPath}` and 9 left behind + * at `/api/v1`, invisible to `{apiPath}/openapi.json` (whose section is + * filtered to this base) and to `/discovery`. + * + * The fix is the SHARING, not the expression: do not copy the `??` chain + * to a second site — copying it is precisely how the divergence happened. + * Call this. */ - private getApiBasePath(): string { + getApiBasePath(): string { const { api } = this.config; return api.apiPath ?? `${api.basePath}/${api.version}`; } @@ -3443,12 +3458,12 @@ export class RestServer { // 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. + // cannot drift. Since #6306 those registrars mount at + // this server's own `getApiBasePath()` — the single + // base — so an `apiPath` deployment advertises + // `{apiPath}/packages` and `{apiPath}/datasources`. + // That move landed with NO edit in this block, which is + // exactly the property #6633 was built to provide. // // A boot that mounted nothing (no `package` service ⇒ // the registrar was never called) advertises nothing: @@ -9294,7 +9309,7 @@ export class RestServer { * * 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 + * in (since #6306 that is `getApiBasePath()`), 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