Skip to content

Commit 3f0dd1d

Browse files
committed
fix(spec)!: DatasourceSchema.name refuses route-shadowed names (drivers)
The reserved set moves to @objectstack/spec as ROUTE_SHADOWED_DATASOURCE_NAMES; service-datasource's admin registrar and create door read the spec list. Claude-Session: https://claude.ai/code/session_01ADHkmS1mEJA9L4pTrajNhU Co-authored-by: Claude <noreply@anthropic.com>
1 parent eef116e commit 3f0dd1d

3 files changed

Lines changed: 202 additions & 11 deletions

File tree

‎packages/services/service-datasource/src/datasource-route-reserved-names.ts‎

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,32 @@
1818
* patched, removed and probed (`POST /datasources/test/test`) through its own
1919
* doors.
2020
*
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
21+
* ONE list for every half, and it is the spec's
22+
* (`ROUTE_SHADOWED_DATASOURCE_NAMES` in `@objectstack/spec/data`), so the
23+
* metadata surface refuses the same names: `DatasourceSchema.name` rejects
24+
* each one at `defineStack`, `os validate`, `os build` and the metadata save
25+
* door. Here, the registrar mounts each shadowing literal from a segment typed
26+
* as a member of that list, and `DatasourceAdminService` refuses each name in
27+
* it at create. The drift pin
2428
* (`__tests__/datasource-route-reserved-names.test.ts`) enumerates what the
2529
* 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.
30+
* same-method `/:name` route that is not listed, and a listed name that no
31+
* longer shadows anything.
2832
*/
2933

30-
/** The driver-catalog literal: `GET /datasources/drivers`, mounted beside `GET /datasources/:name`. */
31-
export const DRIVER_CATALOG_SEGMENT = 'drivers';
34+
import { ROUTE_SHADOWED_DATASOURCE_NAMES as SPEC_ROUTE_SHADOWED_DATASOURCE_NAMES } from '@objectstack/spec/data';
35+
36+
/**
37+
* The driver-catalog literal: `GET /datasources/drivers`, mounted beside
38+
* `GET /datasources/:name`. Typed as a member of the spec's reserved list, so
39+
* a catalog segment the spec does not reserve does not compile.
40+
*/
41+
export const DRIVER_CATALOG_SEGMENT: (typeof SPEC_ROUTE_SHADOWED_DATASOURCE_NAMES)[number] = 'drivers';
3242

3343
/**
3444
* Every literal segment the admin registrar mounts beside a same-method
3545
* `/datasources/:name` route — a datasource of one of these names could never
36-
* be reached through that by-name door.
46+
* be reached through that by-name door. The spec's list itself (the same
47+
* frozen array), widened to `string` for the door's membership test.
3748
*/
38-
export const ROUTE_SHADOWED_DATASOURCE_NAMES: readonly string[] = Object.freeze([DRIVER_CATALOG_SEGMENT]);
49+
export const ROUTE_SHADOWED_DATASOURCE_NAMES: readonly string[] = SPEC_ROUTE_SHADOWED_DATASOURCE_NAMES;
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `DatasourceSchema.name` refuses the route-shadowed names (#29308).
5+
*
6+
* ## What was wrong
7+
*
8+
* The datasource admin API mounts the driver catalog at
9+
* `GET /api/v1/datasources/drivers`, ahead of `GET /api/v1/datasources/:name`,
10+
* so a datasource named `drivers` can never be read by name. The admin create
11+
* door refused it, but `DatasourceSchema.name` accepted it, so the same
12+
* unreachable name was minted from code (`defineStack`) or through the
13+
* metadata save door (`PUT /api/v1/meta/datasource/drivers`).
14+
*
15+
* ## What is pinned
16+
*
17+
* - the reserved set is ONE exported constant, frozen, whose entries are
18+
* identifiers the `name` pattern accepts (so the refusal pattern built from
19+
* them needs no escaping);
20+
* - `DatasourceSchema` refuses every entry: one issue at `name`, an
21+
* `invalid_format` whose message names the name and the shadowing route,
22+
* and carries no tracker number;
23+
* - `defineStack` (the code door) refuses it with the ADR-0112 envelope at
24+
* `datasources.0.name`;
25+
* - the published JSON Schema carries the refusal (a second `pattern`), so a
26+
* JSON-Schema consumer refuses the name too;
27+
* - CONTROLS: near names (`driver`, `drivers_archive`, `my_drivers`), the
28+
* host's `default` row and `test` (a literal that shadows no by-name read)
29+
* still parse.
30+
*
31+
* The admin door's half (the registrar mounts the literal from this list, and
32+
* the create door refuses it) is pinned in
33+
* `packages/services/service-datasource/src/__tests__/datasource-route-reserved-names.test.ts`.
34+
*/
35+
import { describe, it, expect } from 'vitest';
36+
import { z } from 'zod';
37+
import { DatasourceSchema, ROUTE_SHADOWED_DATASOURCE_NAMES } from './datasource.zod';
38+
import { defineStack } from '../stack.zod';
39+
40+
const sqlite = (name: string) => ({ name, driver: 'sqlite', config: { filename: `${name}.db` } });
41+
const manifest = { id: 'com.example.fx', name: 'fx', version: '1.0.0', type: 'app' as const };
42+
43+
type Envelope = Error & {
44+
code?: string;
45+
status?: number;
46+
issues?: ReadonlyArray<{ code?: string; path?: readonly PropertyKey[]; message?: string }>;
47+
};
48+
49+
function refusal(fn: () => unknown): Envelope | null {
50+
try {
51+
fn();
52+
return null;
53+
} catch (e) {
54+
return e as Envelope;
55+
}
56+
}
57+
58+
describe('ROUTE_SHADOWED_DATASOURCE_NAMES', () => {
59+
it('is the frozen list the admin API shadows today: `drivers`', () => {
60+
expect([...ROUTE_SHADOWED_DATASOURCE_NAMES]).toEqual(['drivers']);
61+
expect(Object.isFrozen(ROUTE_SHADOWED_DATASOURCE_NAMES)).toBe(true);
62+
});
63+
64+
it('every entry is an identifier the `name` pattern accepts, so the refusal pattern needs no escaping', () => {
65+
for (const name of ROUTE_SHADOWED_DATASOURCE_NAMES) {
66+
expect(name, name).toMatch(/^[a-z_][a-z0-9_]*$/);
67+
}
68+
});
69+
});
70+
71+
describe('DatasourceSchema.name refuses a route-shadowed name', () => {
72+
for (const name of ROUTE_SHADOWED_DATASOURCE_NAMES) {
73+
it(`refuses '${name}' with one issue at \`name\` naming the name and the route`, () => {
74+
const result = DatasourceSchema.safeParse(sqlite(name));
75+
expect(result.success).toBe(false);
76+
const issues = result.error!.issues;
77+
expect(issues).toHaveLength(1);
78+
expect(issues[0]!.code).toBe('invalid_format');
79+
expect(issues[0]!.path).toEqual(['name']);
80+
const message = issues[0]!.message;
81+
expect(message.startsWith(`Datasource name '${name}' is reserved: `), message).toBe(true);
82+
expect(message).toContain(`/api/v1/datasources/${name}`);
83+
expect(message).toContain('could never be read by name');
84+
expect(message).not.toMatch(/#\d{3,}/);
85+
});
86+
}
87+
88+
it('the code door: defineStack refuses it with STACK_SCHEMA_INVALID / 422 at datasources.0.name', () => {
89+
const refused = refusal(() => defineStack({ manifest, datasources: [sqlite('drivers')] } as never));
90+
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
91+
expect(refused?.status).toBe(422);
92+
const hits = (refused?.issues ?? []).filter((i) => (i.message ?? '').startsWith("Datasource name 'drivers' is reserved"));
93+
expect(hits).toHaveLength(1);
94+
expect(hits[0]!.path).toEqual(['datasources', 0, 'name']);
95+
});
96+
97+
it('the published JSON Schema carries the refusal: some `name` pattern rejects each reserved name', () => {
98+
const json = z.toJSONSchema(DatasourceSchema, { io: 'input', unrepresentable: 'any' }) as {
99+
properties: Record<string, { pattern?: string; allOf?: Array<{ pattern?: string }> }>;
100+
};
101+
const name = json.properties.name!;
102+
const patterns = [name.pattern, ...(name.allOf ?? []).map((s) => s.pattern)]
103+
.filter((p): p is string => typeof p === 'string')
104+
.map((p) => new RegExp(p));
105+
expect(patterns.length).toBeGreaterThanOrEqual(2);
106+
for (const reserved of ROUTE_SHADOWED_DATASOURCE_NAMES) {
107+
expect(patterns.every((p) => p.test(reserved)), reserved).toBe(false);
108+
}
109+
for (const legal of ['reporting', 'drivers_archive', 'driver']) {
110+
expect(patterns.every((p) => p.test(legal)), legal).toBe(true);
111+
}
112+
});
113+
114+
it('CONTROL: near names, `test` and the host `default` row still parse', () => {
115+
for (const name of ['driver', 'drivers_archive', 'my_drivers', '_drivers', 'test', 'default', 'reporting']) {
116+
const result = DatasourceSchema.safeParse(sqlite(name));
117+
expect(result.success, `${name}: ${JSON.stringify(result.error?.issues)}`).toBe(true);
118+
}
119+
});
120+
121+
it('CONTROL: an off-pattern name still meets the identifier pattern, not this refusal', () => {
122+
const result = DatasourceSchema.safeParse(sqlite('Drivers'));
123+
expect(result.success).toBe(false);
124+
expect(result.error!.issues.map((i) => i.message).some((m) => m.includes('is reserved'))).toBe(false);
125+
});
126+
});

‎packages/spec/src/data/datasource.zod.ts‎

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,50 @@ function reportDriverConfigIssues(
589589
}
590590
}
591591

592+
/**
593+
* Datasource names the datasource admin API can never serve by name.
594+
*
595+
* The admin route family mounts its by-name doors at `/api/v1/datasources/:name`
596+
* and, beside them, literal single-segment routes under the same root. Where a
597+
* literal shares a method with a by-name route, it is mounted first and captures
598+
* the path: `GET /api/v1/datasources/drivers` answers the driver catalog, so a
599+
* datasource named `drivers` would be stored and listed while its detail read
600+
* (the load of its edit form) was never reached.
601+
*
602+
* ONE list for every half. `DatasourceSchema.name` refuses each entry, so
603+
* `defineDatasource`, `defineStack`, `os validate`, `os build` and the metadata
604+
* save door (`PUT /api/v1/meta/datasource/NAME`) all refuse it; the admin
605+
* registrar in `@objectstack/service-datasource` mounts each shadowing literal
606+
* from this list, and its create door refuses each name too. A drift pin there
607+
* holds the list equal to the literals the registrar actually mounts beside a
608+
* same-method by-name route, in both directions.
609+
*
610+
* Not here: `default`, the host's primary datasource. A stack may not declare
611+
* it (`defineStack` refuses it), but the host's own `default` row is a valid
612+
* `DatasourceSchema` record, so it is not a refused name.
613+
*/
614+
export const ROUTE_SHADOWED_DATASOURCE_NAMES = Object.freeze(['drivers'] as const);
615+
616+
const quotedRouteShadowedNames = ROUTE_SHADOWED_DATASOURCE_NAMES.map((name) => `'${name}'`).join(', ');
617+
618+
/**
619+
* The refusal `DatasourceSchema.name` answers for a route-shadowed name: it
620+
* names the name and why, in the admin create door's sentence family.
621+
*/
622+
const ROUTE_SHADOWED_NAME_REFUSAL =
623+
`Datasource name ${quotedRouteShadowedNames} is reserved: the datasource admin API serves its own route `
624+
+ `at /api/v1/datasources/${ROUTE_SHADOWED_DATASOURCE_NAMES.join(' and /api/v1/datasources/')}, ahead of `
625+
+ 'the by-name read /api/v1/datasources/:name, so a datasource of that name could never be read by '
626+
+ 'name. Pick another name.';
627+
628+
/**
629+
* A second pattern on `name`, not a refinement: zod emits both patterns into
630+
* the published JSON Schema (`allOf`), so editors and other JSON-Schema
631+
* consumers refuse the name too. The entries are snake_case identifiers (the
632+
* `name` pattern itself, pinned), so none needs escaping.
633+
*/
634+
const NOT_ROUTE_SHADOWED_NAME = new RegExp(`^(?!(?:${ROUTE_SHADOWED_DATASOURCE_NAMES.join('|')})$)`);
635+
592636
/**
593637
* Datasource Schema
594638
* Represents a connection to an external data store.
@@ -648,8 +692,18 @@ export const DatasourceSchema = lazySchema(() => strictObject(
648692
+ 'left the datasource connecting on driver defaults rather than failing.',
649693
},
650694
{
651-
/** Machine Name */
652-
name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Unique datasource identifier'),
695+
/**
696+
* Machine Name. Refuses the route-shadowed names
697+
* ({@link ROUTE_SHADOWED_DATASOURCE_NAMES}): the admin API could never read
698+
* a datasource of that name by name.
699+
*/
700+
name: z.string()
701+
.regex(/^[a-z_][a-z0-9_]*$/)
702+
.regex(NOT_ROUTE_SHADOWED_NAME, ROUTE_SHADOWED_NAME_REFUSAL)
703+
.describe(
704+
`Unique datasource identifier (snake_case). Reserved: ${quotedRouteShadowedNames}, a path the `
705+
+ 'datasource admin API serves itself, so a datasource of that name could never be read by name.',
706+
),
653707

654708
/** Human Label */
655709
label: z.string().optional().describe('Display label'),

0 commit comments

Comments
 (0)