Skip to content

Commit 4771869

Browse files
fix(service-datasource)!: refuse creating a datasource named drivers, the driver-catalog route literal (#29262)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi94YCYBg2S8baVHuFe3JkctW)_ Fixes #29164 Clause-②: no (narrowing) Metadata-protocol: none ## 维护者速读 - **改了什么**:新建运行时数据源时拒绝名字 `drivers`(与 `default` 同样的 400 `DATASOURCE_ADMIN_ERROR`,报错点名该名字);保留名单与路由注册用同一个常量,并有一条从注册器实际挂载推导的漂移测试。 - **为什么改**:`GET /datasources/drivers` 是驱动目录,先于 `GET /datasources/:name` 挂载,所以叫 `drivers` 的数据源能建、能列,但编辑表单永远打不开。 - **风险与回滚**:低。以前 201 的 `drivers` 创建现在 400(收窄);`test` 仍可创建;已存的 `drivers` 行不受影响。回滚即 revert。 - **先看**:`datasource-route-reserved-names.ts`(名单与理由),`datasource-admin-service.ts` 的 `assertValidName`,`__tests__/datasource-route-reserved-names.test.ts` 的漂移测试。 - **两分钟验证**:`pnpm --filter @objectstack/service-datasource exec vitest run src/__tests__/datasource-route-reserved-names.test.ts`,预期 `Tests 7 passed (7)`(main 上 `drivers` 用例红:expected 201 to be 400)。 - **需要你做的**:无,席位落地。 ## What changes - New `src/datasource-route-reserved-names.ts` holds `DRIVER_CATALOG_SEGMENT` and `ROUTE_SHADOWED_DATASOURCE_NAMES` (today `['drivers']`): every literal segment the admin registrar mounts beside a same-method `/datasources/:name` route. Internal module, not exported from the package entry. - `admin-routes.ts` mounts the driver catalog from that constant (`${root}/${DRIVER_CATALOG_SEGMENT}`); the wire path is unchanged. - `assertValidName` (`datasource-admin-service.ts`) refuses each listed name after the existing `default` check, with the same thrown `Error`, so the route answers the same 400 `DATASOURCE_ADMIN_ERROR`. The message names the reserved name. No new error code, status or member. - Changeset `.changeset/29164-reserve-route-shadowed-datasource-names.md`: `patch` on `@objectstack/service-datasource`, BREAKING banner, ADR-0087 `not-required (no-migration-prescription)`, both declaration lines. **Which literals shadow a name (measured on the registrar).** Single-segment literals under `/datasources` are `GET /drivers` and `POST /test`. `/:name` routes exist for GET, PATCH and DELETE only. So `drivers` shadows `GET /:name`; `test` shadows nothing (there is no `POST /:name`; the saved-datasource probe is `POST /:name/test`, a different depth). `test` is therefore not reserved, and a test pins that a datasource named `test` is created (201) and read back by name (200). ## Tests New file `src/__tests__/datasource-route-reserved-names.test.ts` (7 cases). No existing test was edited. - **Drift pin:** runs `registerDatasourceAdminRoutes` against a capturing mock `IHttpServer` (the route-ledger conformance seam), derives the literal single segments that share a method with a `/:name` route, and requires `ROUTE_SHADOWED_DATASOURCE_NAMES` to equal that set. A control asserts the enumeration saw `GET /:name` and `POST /test`. A second case pins the derivation itself: `POST /test` beside `GET /:name` derives nothing, `GET /test` beside it derives `test`. - **Door cases** over the real `HonoHttpServer` and a real `DatasourceAdminService`: `drivers` → 400 `DATASOURCE_ADMIN_ERROR`, message names `'drivers'`, nothing written; `default` → same refusal, unchanged; `reporting` → 201 and read back by name; `test` → 201 and read back by name. - **Stored row:** a `drivers` row seeded in the store stays listed, patchable and removable. <details><summary>Red on base, green on head, three ablations</summary> - **Red** (tests committed first, fix absent, `41bae08f`): `Tests 2 failed | 5 passed (7)`; `drivers` create: "expected 201 to be 400". (The second red was the stored-row case asserting 200 on a DELETE that answers 204; the case now asserts `ok`. Not a behaviour change.) - **Green** (`f085c3c6`): `Tests 7 passed (7)`. - Every ablation ran through `scripts/ablation-replace.mjs` (anchor must hit, blob hashes, restore checked against HEAD). The test imports the service and registrar from source (relative paths), so no build sits between mutation and run. - **Ablation 1:** the new guard became `if (false && ROUTE_SHADOWED_DATASOURCE_NAMES.includes(name))`, anchor 1 → 0, blob `4ec99504` → `4226d0f7`. Result `Tests 1 failed | 6 passed (7)`: the `drivers` refusal case, "expected 201 to be 400". Restored blob `4ec99504` = HEAD. - **Ablation 2:** the `default` guard became `if (false && name === 'default')`, blob `4ec99504` → `fd350350`. Result `Tests 1 failed | 6 passed (7)`: the `default` case, "expected 201 to be 400". Restored = HEAD. - **Ablation 3 (drift pin):** mounted an unreserved `GET ${root}/health` in `admin-routes.ts`, blob `b77801e2` → `214d385c`. Result `Tests 1 failed | 6 passed (7)`: "expected [ 'drivers' ] to deeply equal [ 'drivers', 'health' ]". Restored blob `b77801e2` = HEAD, `git diff HEAD` empty. A first attempt with an anchor the replacement repeated was refused by the tool (anchor count 1 → 1) and restored; it did not run. </details> ## Acceptance notes - **Stored `drivers` rows.** The refusal is at create only, and an update cannot rename (it keeps `existing.name`). A row created before this release stays listed, patchable, removable and probe-able by name; its detail read still answers the catalog. Fixing that read would mean the catalog route resolving a datasource too, a second meaning on one path, so it is left out of scope. The changeset gives the remedy: recreate under another name, rebind the objects, remove the old row. - **Code-declared datasources.** The spec's `DatasourceSchema.name` rule (`/^[a-z_][a-z0-9_]*$/`) also accepts `drivers`, so a datasource declared in code under that name hits the same shadowed detail read. That is the metadata accept surface, spec-lane work, so it is not touched here; it is reported to the seat. - **Only `GET /drivers` is reserved.** Reserving `test` too would refuse a common name that today works through every door. The drift pin reserves it the day a same-method `/:name` sibling mounts. ## Verification All readings at head `2c7aac31` (fix `f085c3c6` plus a merge of `origin/main` at `104cd1a5`). `origin/main` moved 24 commits after that merge; none touches `packages/services/service-datasource`, and PR #29192 (same file, different function) had not landed. <details><summary>Package suite, typecheck, lint</summary> - `pnpm --filter @objectstack/service-datasource test`: `Test Files 70 passed (70)`, `Tests 1159 passed (1159)`, VERDICT command-exit 0. - `pnpm --filter @objectstack/service-datasource typecheck`: exit 0. - `pnpm lint` (repo-wide `eslint . --no-inline-config`): exit 0. </details> <details><summary>Derived gates (dispatch-gates.mjs --commands, 62 families)</summary> - 62 of 62 run locally, each exit 0; `--ran` reconciliation: "62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero, every line carrying its exit code). - Two first exited 3 on prerequisites, not on the diff: `check-plugin-teardown-shape --self-test` on a shallow clone (passed, 48 cases, after `git fetch --unshallow origin main`), and `check:dual-build-cjs-loads` before every package was built (passed after `pnpm build`, 117/117 tasks: 115 require entry points across 70 packages load). </details> <details><summary>Full dogfood battery (owed by the narrowing arm)</summary> - Head `2c7aac31`, closure built with `pnpm --filter '@objectstack/dogfood^...' build`, then `pnpm --filter @objectstack/dogfood test` under the verify lock: VERDICT command-exit 0, `Test Files 388 passed | 3 skipped (391)`, `Tests 3208 passed | 28 skipped (3236)`, no failure. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmPjMhxWBYAk48z4Mam)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bbfccbd commit 4771869

6 files changed

Lines changed: 280 additions & 1 deletion

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/service-datasource': patch
3+
---
4+
5+
fix(service-datasource)!: creating a runtime datasource refuses `drivers`, the name the driver-catalog route answers, so a datasource is never created that its edit form cannot load (#29164)
6+
7+
Clause-②: no (narrowing) — `POST /api/v1/datasources` with `name: 'drivers'`, which answered 201 before, now answers 400 `DATASOURCE_ADMIN_ERROR` naming the reserved name. Nothing is widened.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No schema key, export or stored shape changes: the runtime datasource-admin create door refuses one more name, the way it already refused `default`. A stored row of that name is not rewritten or refused, so there is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing at the datasource-admin create door (`@objectstack/service-datasource`).
13+
14+
- **Before.** `GET /api/v1/datasources/drivers` is the driver catalog, mounted ahead of `GET /api/v1/datasources/:name`. A runtime datasource named `drivers` was created and listed, but its detail read always answered the catalog, so Setup → Datasources could never open its edit form.
15+
- **Now.** Create refuses every literal path segment the admin routes mount beside a same-method `/datasources/:name` route; today that is `drivers` alone. The refusal is the same 400 `DATASOURCE_ADMIN_ERROR` that `default` already answers. `test` stays creatable: `POST /datasources/test` (the unsaved-draft probe) sits beside no `POST /datasources/:name`, so a datasource named `test` keeps every by-name door.
16+
- **Stored rows.** A `drivers` row created before this release is not refused or rewritten. It stays listed, patchable, removable and probe-able by name; its detail read still answers the catalog.
17+
18+
**The remedy.** Create the datasource under another name. For a stored `drivers` row, create the same connection under another name, move the objects bound to `drivers` onto it, then remove `drivers` (`DELETE /api/v1/datasources/drivers`, which refuses while any object is still bound).

‎content/docs/data-modeling/drivers.mdx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,9 @@ through the same datasource connection path — one failure verdict, one escape
278278
hatch, and a real `status` for the primary DB in **Setup → Datasources**. The
279279
name `default` is host-reserved: an app bundle declaring a datasource with that
280280
name is rejected at load.
281+
The name `drivers` is reserved at the datasource admin API: creating a runtime
282+
datasource under it is refused, because `GET /api/v1/datasources/drivers` is the
283+
driver catalog, so a datasource of that name could never be opened by name.
281284

282285
<Callout type="warn">
283286
`OS_ALLOW_DRIVER_CONNECT_FAILURE=1` boots anyway, in an explicitly degraded
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Route-shadowed datasource names (#29164).
5+
*
6+
* `GET /datasources/drivers` is the driver catalog, mounted before
7+
* `GET /datasources/:name`, so a runtime datasource named `drivers` was
8+
* created (201) and listed while its detail read — the edit form's load —
9+
* always answered the catalog. Create now refuses every literal the registrar
10+
* mounts beside a same-method `/:name` route, the way it refuses the
11+
* host-owned `default`.
12+
*
13+
* Two halves:
14+
* - the DRIFT PIN derives the shadowing literals from what
15+
* `registerDatasourceAdminRoutes` actually mounts (a capturing mock
16+
* `IHttpServer`, the route-ledger conformance seam) and holds the reserved
17+
* list equal to them, so a new literal sibling of `/:name` mounted without
18+
* a reservation fails here;
19+
* - the DOOR cases run the real `HonoHttpServer` over a real
20+
* `DatasourceAdminService`: the refusal envelope, the service never
21+
* writing, `default` unchanged, and the names that stay creatable.
22+
*/
23+
24+
import { describe, it, expect, vi } from 'vitest';
25+
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
26+
import { registerDatasourceAdminRoutes } from '../admin-routes.js';
27+
import {
28+
DatasourceAdminService,
29+
type DatasourceAdminServiceConfig,
30+
type StoredDatasource,
31+
} from '../datasource-admin-service.js';
32+
import { ROUTE_SHADOWED_DATASOURCE_NAMES } from '../datasource-route-reserved-names.js';
33+
import {
34+
ENTITLED_CREDENTIAL,
35+
createSessionAuthService,
36+
createGrantsEngine,
37+
} from './entitled-caller.fixture.js';
38+
39+
const BASE = '/api/v1';
40+
const ROOT = `${BASE}/datasources`;
41+
const VERBS = ['get', 'post', 'put', 'patch', 'delete'] as const;
42+
43+
/** Every `[VERB, path]` the registrar mounts at the default base. */
44+
function enumerateMounts(): Array<[string, string]> {
45+
const server = Object.fromEntries([
46+
...VERBS.map((v) => [v, vi.fn()]),
47+
['use', vi.fn()],
48+
['listen', vi.fn().mockResolvedValue(undefined)],
49+
['close', vi.fn().mockResolvedValue(undefined)],
50+
]) as Record<(typeof VERBS)[number], ReturnType<typeof vi.fn>>;
51+
const ctx = { getService: () => undefined, logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } };
52+
registerDatasourceAdminRoutes(server as never, ctx as never, BASE);
53+
return VERBS.flatMap((v) => server[v].mock.calls.map((c): [string, string] => [v.toUpperCase(), String(c[0])]));
54+
}
55+
56+
/**
57+
* The literal single segments under the root that share a method with a
58+
* `/:name` route — the names that route would never see.
59+
*/
60+
function shadowingLiterals(mounts: Array<[string, string]>): string[] {
61+
const byNameMethods = new Set(mounts.filter(([, p]) => p === `${ROOT}/:name`).map(([m]) => m));
62+
const literals = new Set<string>();
63+
for (const [method, path] of mounts) {
64+
if (!path.startsWith(`${ROOT}/`)) continue;
65+
const rest = path.slice(ROOT.length + 1);
66+
if (rest.includes('/') || rest.startsWith(':')) continue;
67+
if (byNameMethods.has(method)) literals.add(rest);
68+
}
69+
return [...literals].sort();
70+
}
71+
72+
describe('reserved datasource names ↔ the registrar\'s literal siblings of /:name', () => {
73+
it('reserves exactly the literals that shadow a same-method /:name route', () => {
74+
const mounts = enumerateMounts();
75+
// Control: the enumeration saw the by-name read and a literal at all, so
76+
// an empty answer below could not be a silent nothing-enumerated.
77+
expect(mounts).toContainEqual(['GET', `${ROOT}/:name`]);
78+
expect(mounts).toContainEqual(['POST', `${ROOT}/test`]);
79+
80+
expect([...ROUTE_SHADOWED_DATASOURCE_NAMES].sort()).toEqual(shadowingLiterals(mounts));
81+
});
82+
83+
it('a literal beside a /:name route of ANOTHER method is not a shadowing one', () => {
84+
// `POST /datasources/test` shares no method with a `/:name` route; were a
85+
// `GET /datasources/test` mounted, `test` would join the derived set and
86+
// the pin above would fail until it is reserved.
87+
expect(shadowingLiterals([['POST', `${ROOT}/test`], ['GET', `${ROOT}/:name`]])).toEqual([]);
88+
expect(shadowingLiterals([['GET', `${ROOT}/test`], ['GET', `${ROOT}/:name`]])).toEqual(['test']);
89+
});
90+
});
91+
92+
// ---------------------------------------------------------------------------
93+
// The create door, end to end.
94+
95+
function harness(seed: StoredDatasource[] = []) {
96+
const records: StoredDatasource[] = seed.map((r) => ({ ...r }));
97+
const puts: string[] = [];
98+
const config: DatasourceAdminServiceConfig = {
99+
probe: async () => ({ ok: true, latencyMs: 1 }),
100+
listDatasourceRecords: async () => records.map((r) => ({ ...r })),
101+
getDatasourceRecord: async (n) => {
102+
const r = records.find((x) => x.name === n);
103+
return r ? { ...r } : undefined;
104+
},
105+
putDatasourceRecord: async (record) => {
106+
puts.push(record.name);
107+
const i = records.findIndex((r) => r.name === record.name);
108+
if (i >= 0) records[i] = { ...record };
109+
else records.push({ ...record });
110+
},
111+
deleteDatasourceRecord: async (n) => {
112+
const i = records.findIndex((r) => r.name === n);
113+
if (i >= 0) records.splice(i, 1);
114+
},
115+
writeSecret: async (_input, hint) => `sys_secret://datasource/${hint.name}`,
116+
countBoundObjects: async () => 0,
117+
listObjectsBoundTo: async () => [],
118+
};
119+
const service = new DatasourceAdminService(config);
120+
const authService = createSessionAuthService();
121+
const grantsEngine = createGrantsEngine();
122+
const server = new HonoHttpServer(0);
123+
const ctx = {
124+
getService: (name: string) =>
125+
name === 'auth'
126+
? authService
127+
: name === 'objectql' || name === 'data'
128+
? grantsEngine
129+
: name === 'datasource-admin'
130+
? service
131+
: undefined,
132+
};
133+
registerDatasourceAdminRoutes(server, ctx as never, BASE);
134+
const app = server.getRawApp();
135+
const call = (path: string, method = 'GET', body?: unknown) =>
136+
app.fetch(
137+
new Request(`http://local${path}`, {
138+
method,
139+
headers: { 'content-type': 'application/json', authorization: ENTITLED_CREDENTIAL },
140+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
141+
}),
142+
);
143+
return { call, puts, records };
144+
}
145+
146+
const draft = (name: string) => ({ name, driver: 'postgres', config: { database: 'analytics' } });
147+
148+
describe('POST /api/v1/datasources — route-shadowed names', () => {
149+
it('refuses `drivers` with the 400 DATASOURCE_ADMIN_ERROR naming the segment, and writes nothing', async () => {
150+
const { call, puts, records } = harness();
151+
const res = await call(ROOT, 'POST', draft('drivers'));
152+
expect(res.status).toBe(400);
153+
const body = (await res.json()) as { error: { code: string; message: string } };
154+
expect(body.error.code).toBe('DATASOURCE_ADMIN_ERROR');
155+
expect(body.error.message).toContain("'drivers'");
156+
expect(puts).toEqual([]);
157+
expect(records).toEqual([]);
158+
});
159+
160+
it('keeps refusing the host-owned `default` the same way', async () => {
161+
const { call, puts } = harness();
162+
const res = await call(ROOT, 'POST', draft('default'));
163+
expect(res.status).toBe(400);
164+
const body = (await res.json()) as { error: { code: string; message: string } };
165+
expect(body.error.code).toBe('DATASOURCE_ADMIN_ERROR');
166+
expect(body.error.message).toContain("'default'");
167+
expect(puts).toEqual([]);
168+
});
169+
170+
it('still creates an ordinary name (201) and reads it back by name', async () => {
171+
const { call, puts } = harness();
172+
const res = await call(ROOT, 'POST', draft('reporting'));
173+
expect(res.status).toBe(201);
174+
expect(puts).toEqual(['reporting']);
175+
const read = await call(`${ROOT}/reporting`);
176+
expect(read.status).toBe(200);
177+
expect(((await read.json()) as any).data.datasource.name).toBe('reporting');
178+
});
179+
180+
it('still creates `test` — the draft-probe literal shadows no by-name door', async () => {
181+
const { call, puts } = harness();
182+
const res = await call(ROOT, 'POST', draft('test'));
183+
expect(res.status).toBe(201);
184+
expect(puts).toEqual(['test']);
185+
const read = await call(`${ROOT}/test`);
186+
expect(read.status).toBe(200);
187+
expect(((await read.json()) as any).data.datasource.name).toBe('test');
188+
});
189+
});
190+
191+
describe('a stored `drivers` row written before the refusal', () => {
192+
it('stays listed, patchable and removable — the refusal is at create only', async () => {
193+
const { call, records } = harness([{ name: 'drivers', driver: 'postgres', config: { database: 'a' }, origin: 'runtime' }]);
194+
195+
const list = await call(ROOT);
196+
expect(list.status).toBe(200);
197+
expect(((await list.json()) as any).data.datasources.map((d: { name: string }) => d.name)).toContain('drivers');
198+
199+
const patched = await call(`${ROOT}/drivers`, 'PATCH', { label: 'Old drivers' });
200+
expect(patched.status).toBe(200);
201+
expect(records.find((r) => r.name === 'drivers')?.label).toBe('Old drivers');
202+
203+
const removed = await call(`${ROOT}/drivers`, 'DELETE');
204+
expect(removed.ok).toBe(true);
205+
expect(records.some((r) => r.name === 'drivers')).toBe(false);
206+
});
207+
});

‎packages/services/service-datasource/src/admin-routes.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import type { IAuthService, IDataEngine, IHttpServer } from '@objectstack/spec/c
4545
// The declared envelope is written in ONE place for the whole platform (#3973).
4646
import { inProcessSessionReadInput, sendOk, sendError } from '@objectstack/types';
4747
import { DRIVER_CATALOG } from './driver-catalog.js';
48+
import { DRIVER_CATALOG_SEGMENT } from './datasource-route-reserved-names.js';
4849
// [#27618] The package's one "no such datasource" refusal: GET answers an
4950
// unknown name in its words, as every other by-name door does.
5051
import { datasourceNotFoundError } from './datasource-admin-service.js';
@@ -692,7 +693,7 @@ export function registerDatasourceAdminRoutes(
692693
// Catalog of connection drivers + their JSON-Schema config (drives the
693694
// Studio connection form). Static metadata — no service dependency, so it
694695
// is always available even before any datasource-admin service is wired.
695-
server.get(`${root}/drivers`, async (req: any, res: any) => {
696+
server.get(`${root}/${DRIVER_CATALOG_SEGMENT}`, async (req: any, res: any) => {
696697
if (await requireDatasourceAdmin(req, res)) return;
697698
sendOk(res, { drivers: DRIVER_CATALOG });
698699
});

‎packages/services/service-datasource/src/datasource-admin-service.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { datasourceConnectivityChanged } from './datasource-connectivity-change.
4343
import { redactDatasourceConfig, restoreRedactedConfig } from './datasource-config-redaction.js';
4444
import { planCredentialMigration } from './datasource-credential-migration.js';
4545
import { borrowedConnectorOf } from './datasource-connector-credential.js';
46+
import { ROUTE_SHADOWED_DATASOURCE_NAMES } from './datasource-route-reserved-names.js';
4647
import type {
4748
IDatasourceAdminService,
4849
DatasourceDraft,
@@ -1401,6 +1402,17 @@ export class DatasourceAdminService implements IDatasourceAdminService {
14011402
`Datasource name 'default' is reserved for the host's primary datasource. Pick another name.`,
14021403
);
14031404
}
1405+
// Route-shadowed names (#29164): a literal the admin registrar mounts beside
1406+
// a same-method `/datasources/:name` route captures that path first, so a
1407+
// datasource of that name could never be read through its by-name door
1408+
// (`GET /datasources/drivers` is the driver catalog, not the edit form's
1409+
// detail read). The list is the one the registrar mounts from.
1410+
if (ROUTE_SHADOWED_DATASOURCE_NAMES.includes(name)) {
1411+
throw new Error(
1412+
`Datasource name '${name}' is reserved: the datasource admin API serves its own route at `
1413+
+ `'/datasources/${name}', so a datasource of that name could never be read by name. Pick another name.`,
1414+
);
1415+
}
14041416
}
14051417

14061418
/**
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Datasource names the admin route family can never serve by name (#29164).
5+
*
6+
* `registerDatasourceAdminRoutes` mounts the by-name doors at
7+
* `/datasources/:name` and, beside them, literal single-segment routes under
8+
* the same root. Where a literal shares a METHOD with a `/:name` route it is
9+
* registered first and captures the path: `GET /datasources/drivers` answers
10+
* the driver catalog, so a datasource named `drivers` could be created and
11+
* listed while its detail read — the load of its edit form — was never
12+
* reached. Each such literal is therefore a reserved datasource name, refused
13+
* at create the same way the host-owned `default` is.
14+
*
15+
* A literal that shares no method with a `/:name` route captures no name, and
16+
* is not reserved: `POST /datasources/test` (the unsaved-draft probe) sits
17+
* beside no `POST /datasources/:name`, so a datasource named `test` is read,
18+
* patched, removed and probed (`POST /datasources/test/test`) through its own
19+
* doors.
20+
*
21+
* ONE list for both halves. The registrar mounts each shadowing literal from
22+
* the constant below, and `DatasourceAdminService` refuses each name in
23+
* {@link ROUTE_SHADOWED_DATASOURCE_NAMES}. The drift pin
24+
* (`__tests__/datasource-route-reserved-names.test.ts`) enumerates what the
25+
* registrar actually mounts and fails both ways: a literal sibling of a
26+
* same-method `/:name` route that is not listed here, and a listed name that
27+
* no longer shadows anything.
28+
*/
29+
30+
/** The driver-catalog literal: `GET /datasources/drivers`, mounted beside `GET /datasources/:name`. */
31+
export const DRIVER_CATALOG_SEGMENT = 'drivers';
32+
33+
/**
34+
* Every literal segment the admin registrar mounts beside a same-method
35+
* `/datasources/:name` route — a datasource of one of these names could never
36+
* be reached through that by-name door.
37+
*/
38+
export const ROUTE_SHADOWED_DATASOURCE_NAMES: readonly string[] = Object.freeze([DRIVER_CATALOG_SEGMENT]);

0 commit comments

Comments
 (0)