Skip to content

Commit 819b101

Browse files
fix(service-datasource)!: refuse every top-level key DatasourceSchema refuses at the datasource test, create and update doors (#28632)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X1Tmz1BBW7x71pyEcvjiBAW)_ Fixes #28612 Clause-②: no (narrowing) — three doors refuse top-level keys they silently dropped: `POST /api/v1/datasources/test` (a top-level `password` / `connectionString`, and any other key `DatasourceSchema` refuses), `POST /api/v1/datasources` and `PATCH /api/v1/datasources/NAME` (every key the schema refuses, beyond the two credential keys PR #28607 closed). Each now answers `400 DATASOURCE_ADMIN_ERROR` before anything is read, written or probed. Nothing is widened. Metadata-protocol: none Skills-docs: none — no page under `skills/**` or `content/docs` teaches the admin request body; the refusal text is `DatasourceSchema`'s own, which already ships with the spec and does not move. **BREAKING** accept-set narrowing at the datasource admin doors (`@objectstack/service-datasource`). **Built on PR #28607** (same file, landed as `f3d84b33`): this branch started from its head and has merged `origin/main` since, so the diff is only this change. ## 维护者速读 - **改了什么**:数据源管理接口的三个入口(连接测试 `POST /api/v1/datasources/test`、创建 `POST /api/v1/datasources`、编辑 `PATCH /api/v1/datasources/NAME`)现在会拒收请求体顶层任何 `DatasourceSchema` 不认识的键:400 `DATASOURCE_ADMIN_ERROR`,点名这个键,不回显值,什么都不写入、也不发起探测。哪些键算"不认识"、提示怎么说,都在运行时直接取自 `DatasourceSchema` 自己的报错(即 `os compile` 打印的那段),服务里没有手写清单。 - **为什么改**:卡 #28612(#28607 合约评审 6555221422 的两条 should-fix):连接测试会悄悄丢掉顶层 `password`,测的其实是另一个连接;三个入口都会悄悄丢掉 `url`、`host`、`type`、`readReplicas`、拼错的键等,接口照样答成功。 - **风险与回滚**:schema 声明过的键(`origin`、`description`、`ssl`、`autoConnect` 等)照常通过;`secret` 由路由先拆走,仍是唯一的凭据通道。控制台、`packages/client`、CLI、QA 清单与 dogfood 都不发被拒的键。唯一可能受影响的是把 `GET` 的应答(含 `hasSecret`、`redactedConfigKeys` 等)原样 PATCH 回来的客户端,仓内实测没有这样的生产者。回滚即 revert。 - **先看**:`packages/services/service-datasource/src/datasource-admin-service.ts`(`assertNoUndeclaredTopLevelKey` 与 `testConnection` 开头的新调用);`packages/services/service-datasource/src/__tests__/datasource-admin-top-level-credential.test.ts`(#28612 的三个 describe);`.changeset/28612-datasource-undeclared-top-level-key.md`。 - **两分钟验证**:`pnpm --filter @objectstack/service-datasource exec vitest run src/__tests__/datasource-admin-top-level-credential.test.ts` → 25/25 通过。 - **需要你做的**:无,席位落地(收窄需先过合约评审)。 ## What changes **One check, three doors** (`packages/services/service-datasource/src/datasource-admin-service.ts`). PR #28607's private `assertNoTopLevelCredential(name, body)` and its hand list `TOP_LEVEL_CREDENTIAL_KEYS` become `assertNoUndeclaredTopLevelKey(heading, body)`, with no list: - It takes the body's own top-level key names (those whose value is not `undefined`), gives each the value `null`, parses that skeleton through `DatasourceSchema`, and keeps only the strict shape's root `unrecognized_keys` refusal. So the key set is exactly what the schema refuses at run time, and the message is the schema's own (its `guidance` rows for `password`, `connectionString`, `url`, `host`, `connector`, the retired blocks, its alias table for `type` and the like, and its generic unknown-key text for anything else). Values never reach the parse, so the answer cannot echo a credential. - `testConnection` (the unsaved-draft probe): first statement, before the driver requirement, the config judgement, the record judgement and the probe. It throws, so the route answers `400 DATASOURCE_ADMIN_ERROR` (the card asks for that code), under the heading this door already gives a draft it refuses: `This connection draft is not a valid datasource.` - `createDatasource` and `updateDatasource`: the same position as #28607's call (create: after the name rule and the draft `credentialsRef` refusal; update: after the 404 and the code-defined refusal, before the merge, on every patch including a `label`/`active`-only one), under the heading `Invalid datasource 'NAME'.` **Door-only fields.** The service's `DatasourceDraft` declares `name`, `label`, `driver`, `schemaMode`, `config`, `external`, `pool`, `active`: all declared by the schema. The one field the doors accept on purpose that the schema does not carry is **`secret`**, the credential channel: the route's `splitSecret` removes it before the service sees the draft, so it never reaches the check (controls below, on all three doors). No other door-only field exists. **Keys the schema declares pass.** `origin`, `description`, `ssl`, `autoConnect`, `name` on an edit, and the metadata protection fields are declared, so the key check lets them through even where the projection leaves them out (`origin` is still stamped `runtime` by the door). That is the card's scope: refuse what the schema refuses. ## Tests Three new `describe` blocks in `src/__tests__/datasource-admin-top-level-credential.test.ts`, same harness as #28607 (real routes over `HonoHttpServer`, real `DatasourceAdminService` on in-memory stores, `entitled-caller.fixture`). The probe seam now records `probe` in the op log, so "nothing probed" is read, not assumed. - **Test door refusals** (status 400 + `error.code` `DATASOURCE_ADMIN_ERROR` + `success: false`, exact message = heading + `DatasourceSchema`'s refusal derived in the test, value absent, op log empty so no probe ran): top-level `password`, `connectionString` (URL userinfo), `foo`, and `password` beside a `secret`. - **Create refusals** (same envelope, exact message, nothing stored or minted): `foo`, `url` (URL userinfo), `type` (the schema's alias row), `readReplicas` (a retired block), and `foo` + `host` + `password` in one answer. - **Edit refusals** (same envelope, exact message, stored record unchanged): `foo` with a `config`, `foo` on a `label`-only edit, `hasSecret` (a `GET` answer key) on a `label`-only edit. - **Controls**: the test door still probes a valid draft whose credential arrives as `secret` (200, `ok: true`, one probe); create with `description` and `origin` (declared) answers 201 and stores `origin: 'runtime'`; an edit carrying `name` and `origin` answers 200. #28607's controls (secret-bound create 201, edit keeping its bound ref 200, `config.password` refusal) stay green. <details><summary>Red first, green, ablation</summary> - **Red first** (the new cases run against PR #28607's head `0cc981aa`, service unchanged): `Tests 12 failed | 13 passed (25)`. The test door answered `expected { status: 200, success: true, … } to deeply equal { status: 400, success: false, … }` (the probe ran without the key), create answered `status: 201`, edit answered `status: 200`. All controls, #28607's included, were green. - **Green** at the fix: `Tests 25 passed (25)`. - **Ablation**, one door at a time (`node scripts/ablation-replace.mjs`, each call to `assertNoUndeclaredTopLevelKey` replaced by `void input;` / `void patch;`; the test imports the service from `src/`, so no build sits between the mutation and the run): - test door: anchor 1 → 0, blob `30943306cc1c` → `ef800604f535`; `Tests 4 failed | 21 passed`, exactly the four test-door refusals. - create: anchor 1 → 0, blob `30943306cc1c` → `32f0578239a4`; `Tests 9 failed | 16 passed`, the four #28549 POST refusals and the five #28612 POST refusals. - update: anchor 1 → 0, blob `30943306cc1c` → `3c4b56a5464b`; `Tests 6 failed | 19 passed`, the three #28549 PATCH refusals and the three #28612 PATCH refusals. - each restore: blob after restore equals HEAD (`30943306cc1c`), `git diff HEAD` empty. </details> ## Acceptance notes - **The `GET` round-trip keys are refused, not tolerated.** #28607's contract review noted that a general closed body "must tolerate the GET round-trip keys `origin`, `hasSecret`, `redactedConfigKeys`, `definedIn`". Measured: `origin` is declared by the schema and passes. The other three (and the list row's `status` / `statusReason`) are not declared, so the card's item 2 ("every top-level key `DatasourceSchema` refuses") refuses them, and no producer in the repo sends them back: the Console editor's `draftBody` builds a fresh body (`label`, `driver`, `schemaMode`, `config`, `external`, `secret`) and its test call sends `driver`, `config`, `secret`; `packages/client` has no create/update/test method; the CLI datasource commands are read-only; the QA checklist bodies and the dogfood suites send declared keys only. Tolerating them would be a consumer-side allowance for keys nothing writes. The changeset's remedy line says not to send them back. - **Keys the schema declares that the doors still drop.** `description`, `ssl`, `autoConnect` (and the protection fields) pass the key check and are then left out by `toRecord` / the update merge, as before. They are accepted by the schema, so they are outside this card ("refuse what the schema refuses"); reported to the seat as an out-of-scope finding. - **Producers measured** (order's stop-and-report check): none sends a top-level key the schema refuses. Searched: `ui/packages/app-shell/src/views/metadata-admin/datasource/`, `packages/client/src`, `packages/cli/src`, `packages/qa/dogfood/test`, `docs/qa/platform-checklist/areas`, `examples/`, `apps/`, `skills/`, `content/docs`. - **Test door answer shape.** The probe's existing refusals (no driver, bad config, invalid record) answer `200` with `result.ok: false`. This one answers `400 DATASOURCE_ADMIN_ERROR`, as the card's item 1 asks ("the same `DATASOURCE_ADMIN_ERROR`"): a key that changes what is being tested is a malformed request, not a failed connection. - **The #28549 changeset** says "Other unknown top-level keys are still dropped as before". That changeset is not edited; this PR's changeset carries the superseding sentence. ## Verification All readings on head `f1438f52` (the merge of `origin/main` at `de3fafcb`, after #28607 landed as `f3d84b33`; the diff against `main` is the three files above). - **Package:** `pnpm --filter @objectstack/service-datasource test` → Test Files 67 passed (67), Tests 1125 passed (1125). `pnpm --filter @objectstack/service-datasource typecheck` exit 0; `tsc --listFiles` includes the test file. - **Derived gate families:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` → 61 families (same list before and after the merge), each run after a full `pnpm build --concurrency=2` (117/117) with its exit code captured before any pipe and stdin from `/dev/null`: 61 × exit 0. `--ran`: `✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 61 run, 0 NOT-MEASURED`. - **Hand gates:** `node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD` exit 0; `node scripts/check-platform-checklist.mjs --changed-from origin/main` exit 0 (no anchored file in the change set); `pnpm exec eslint --no-inline-config packages/services/service-datasource` exit 0, no findings. The repo-wide `pnpm lint` is CI's. - **Full dogfood** (owed by the `!` and the `(narrowing)` arm), after the full build, under the lock, `pnpm --filter @objectstack/dogfood test` → Test Files 1 failed | 364 passed | 3 skipped (368), Tests 1 failed | 2938 passed | 28 skipped (2967). The one failure is not this change: `route-ledger-live-mount-parity.dogfood.test.ts` reports `runtime/route-ledger.ts: GET /packages/:id/customizations — LEDGERED BUT NOT MOUNTED`, a runtime route that arrived with #28595 (`519089e1`) in the merged `main`. This diff touches only `service-datasource`. Reported to the seat; not fixed here. - **CI** families (Test Core incl. `test:repo`, the lint farm, the type-check lanes) are read off this PR's check-runs by the seat. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5nv4Jd9m4jHpZgRJVT --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c90a0b4 commit 819b101

3 files changed

Lines changed: 177 additions & 33 deletions

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)!: `POST /api/v1/datasources/test`, `POST /api/v1/datasources` and `PATCH /api/v1/datasources/:name` refuse every top-level key `DatasourceSchema` refuses, with that schema's own message, instead of dropping it
6+
7+
Clause-②: no (narrowing) — a test, create or edit body carrying a top-level key the datasource schema does not declare (a `password` on the test door; on all three doors a `url` or `host` one level too high, an alias such as `type`, a retired block such as `readReplicas`, or any unknown key), which the test door probed without and the write doors answered 201 / 200 with the key silently dropped, is refused with `400 DATASOURCE_ADMIN_ERROR`. Nothing is widened.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: the datasource admin doors now refuse the top-level keys that `DatasourceSchema` already refuses at the build door, with that schema's own message. A refused body was never stored (the key was dropped), so there is nothing at rest for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing at the datasource admin doors (`@objectstack/service-datasource`).
13+
14+
- **Before.** The unsaved-draft probe and the two write doors built their record from its known keys only. A top-level `password` on the probe vanished, so the probe tested a different connection than the one described. On all three doors any other key the strict schema refuses vanished the same way, and the write answered success.
15+
- **Now.** A top-level key `DatasourceSchema` does not declare is refused before anything is read, written or probed, naming the key and never its value. The message is the schema's own refusal, the one `os compile` prints: for example `url` belongs inside `config`, `type` is `driver`, `readReplicas` is retired. The test door answers under the heading `This connection draft is not a valid datasource.`; create and edit under `Invalid datasource 'NAME'.`. This supersedes the "other unknown top-level keys are still dropped as before" line of the #28549 entry.
16+
- **Unchanged.** Keys the schema declares pass the check, including the ones the doors do not store (`origin`, `description`, `ssl`, `autoConnect`). A credential sent as `secret` is still split off by the route and bound at `external.credentialsRef`. The probe's other answers (`ok: false` with a reason) are unchanged.
17+
18+
**The remedy.** Remove the key, or move it where the message says: connection keys into `config`, the credential into `secret`. Keys copied from a `GET /api/v1/datasources` or `GET /api/v1/datasources/NAME` answer that the schema does not declare (`hasSecret`, `redactedConfigKeys`, `definedIn`, `status`, `statusReason`) are not sent back on an edit.

‎packages/services/service-datasource/src/__tests__/datasource-admin-top-level-credential.test.ts‎

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ function makeDoor(seed: StoredDatasource[] = []) {
5050
const ops: string[] = [];
5151

5252
const config: DatasourceAdminServiceConfig = {
53-
probe: async () => ({ ok: true, latencyMs: 1 }),
53+
probe: async () => {
54+
ops.push('probe');
55+
return { ok: true, latencyMs: 1 };
56+
},
5457
listDatasourceRecords: async () => [...records.values()].map((r) => structuredClone(r)),
5558
getDatasourceRecord: async (name) => {
5659
const r = records.get(name);
@@ -275,3 +278,123 @@ describe('#28549 — controls: the doors keep their other answers', () => {
275278
expect(ops).toEqual([]);
276279
});
277280
});
281+
282+
/**
283+
* #28612 — the rest of #28549: the unsaved-draft probe, and every top-level
284+
* key `DatasourceSchema` refuses, not only the two credential keys.
285+
*
286+
* `POST /api/v1/datasources/test` built its probe through the same known-key
287+
* projection, so a top-level `password` vanished and the probe tested a
288+
* different connection than the one described. And the write doors dropped
289+
* every other key the strict shape refuses (`foo`, a `url` one level too high,
290+
* an alias such as `type`). All three doors now give the schema's own
291+
* root refusal for whatever top-level keys it does not declare. Keys the
292+
* schema declares stay accepted, and `secret` (split off by the route before
293+
* the service sees the draft) stays the credential channel.
294+
*/
295+
describe('#28612 — POST /api/v1/datasources/test refuses a top-level key the schema refuses', () => {
296+
const PROBE_HEADING = 'This connection draft is not a valid datasource.';
297+
298+
it.each([
299+
['password', { password: PLACEHOLDER_PASSWORD }],
300+
['connectionString', { connectionString: PLACEHOLDER_DSN }],
301+
['foo', { foo: 'bar' }],
302+
])('refuses a top-level `%s` before any probe runs', async (key, extra) => {
303+
const { send, records, secrets, ops } = makeDoor();
304+
305+
const message = await expectRefusal(await send('POST', '/datasources/test', { ...BASE, ...extra }));
306+
307+
expect(message).toBe(`${PROBE_HEADING}\n${specRefusal([key])}`);
308+
expect(message).not.toContain(PLACEHOLDER_PASSWORD);
309+
expect(records.size).toBe(0);
310+
expect(secrets.size).toBe(0);
311+
expect(ops).toEqual([]);
312+
});
313+
314+
it('refuses it beside a supplied `secret` too', async () => {
315+
const { send, ops } = makeDoor();
316+
317+
const message = await expectRefusal(
318+
await send('POST', '/datasources/test', { ...BASE, password: PLACEHOLDER_PASSWORD, secret: PLACEHOLDER_PASSWORD }),
319+
);
320+
321+
expect(message).toBe(`${PROBE_HEADING}\n${specRefusal(['password'])}`);
322+
expect(message).not.toContain(PLACEHOLDER_PASSWORD);
323+
expect(ops).toEqual([]);
324+
});
325+
});
326+
327+
describe('#28612 — the write doors refuse every top-level key the schema refuses', () => {
328+
it.each([
329+
['foo', { foo: 'bar' }],
330+
['url', { url: PLACEHOLDER_DSN }],
331+
['type', { type: 'postgres' }],
332+
['readReplicas', { readReplicas: [] }],
333+
])('POST refuses a top-level `%s`, storing nothing', async (key, extra) => {
334+
const { send, records, secrets, ops } = makeDoor();
335+
336+
const message = await expectRefusal(await send('POST', '/datasources', { name: 'u_key', ...BASE, ...extra }));
337+
338+
expect(message).toBe(`Invalid datasource 'u_key'.\n${specRefusal([key])}`);
339+
expect(message).not.toContain(PLACEHOLDER_PASSWORD);
340+
expect(records.has('u_key')).toBe(false);
341+
expect(secrets.size).toBe(0);
342+
expect(ops).toEqual([]);
343+
});
344+
345+
it('POST names every refused key in one answer', async () => {
346+
const { send, records } = makeDoor();
347+
348+
const message = await expectRefusal(
349+
await send('POST', '/datasources', { name: 'u_many', ...BASE, foo: 1, host: 'db.example.invalid', password: PLACEHOLDER_PASSWORD }),
350+
);
351+
352+
expect(message).toBe(`Invalid datasource 'u_many'.\n${specRefusal(['foo', 'host', 'password'])}`);
353+
expect(records.has('u_many')).toBe(false);
354+
});
355+
356+
it.each([
357+
['foo', { config: BASE.config, foo: 'bar' }],
358+
['foo', { label: 'Warehouse', foo: 'bar' }],
359+
['hasSecret', { label: 'Warehouse', hasSecret: false }],
360+
])('PATCH refuses a top-level `%s`, leaving the stored record as it was', async (key, body) => {
361+
const { send, records, ops } = makeDoor([STORED]);
362+
363+
const message = await expectRefusal(await send('PATCH', '/datasources/wh', body));
364+
365+
expect(message).toBe(`Invalid datasource 'wh'.\n${specRefusal([key])}`);
366+
expect(records.get('wh')).toEqual(STORED);
367+
expect(ops).toEqual([]);
368+
});
369+
});
370+
371+
describe('#28612 — controls: declared keys and the secret channel still pass', () => {
372+
it('still probes a valid draft, its credential arriving as `secret`', async () => {
373+
const { send, ops } = makeDoor();
374+
375+
const res = await send('POST', '/datasources/test', { ...BASE, secret: PLACEHOLDER_PASSWORD });
376+
377+
expect(res.status).toBe(200);
378+
const body = (await res.json()) as { data?: { result?: { ok: boolean } } };
379+
expect(body.data?.result?.ok).toBe(true);
380+
expect(ops).toEqual(['probe']);
381+
});
382+
383+
it('still creates with a key the schema declares and the projection leaves out (`description`, `origin`)', async () => {
384+
const { send, records } = makeDoor();
385+
386+
const res = await send('POST', '/datasources', { name: 'wh_decl', ...BASE, description: 'Warehouse', origin: 'code' });
387+
388+
expect(res.status).toBe(201);
389+
expect(records.get('wh_decl')?.origin).toBe('runtime');
390+
});
391+
392+
it('still edits with a key the schema declares (`name`, `origin`)', async () => {
393+
const { send, records } = makeDoor([STORED]);
394+
395+
const res = await send('PATCH', '/datasources/wh', { name: 'wh', origin: 'runtime', label: 'Warehouse' });
396+
397+
expect(res.status).toBe(200);
398+
expect(records.get('wh')?.label).toBe('Warehouse');
399+
});
400+
});

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

Lines changed: 35 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -86,17 +86,6 @@ const PROBE_DRAFT_NAME = 'connection_test_draft';
8686
*/
8787
const RECORD_JUDGED_PATCH_KEYS = ['driver', 'schemaMode', 'config', 'external', 'pool'] as const;
8888

89-
/**
90-
* [#28549] The top-level body keys a credential arrives under that the record
91-
* projection ({@link DatasourceAdminService.toRecord}, the update merge) would
92-
* otherwise drop without a word: a `password`, and a `connectionString` that
93-
* can carry one as URL userinfo. `DatasourceSchema` refuses both at the build
94-
* door; these doors judge exactly these keys through it. Not every key the
95-
* projection drops: those carry no credential, and a closed body at these
96-
* doors is a change of its own.
97-
*/
98-
const TOP_LEVEL_CREDENTIAL_KEYS = ['password', 'connectionString'] as const;
99-
10089
/** A copy of `record` carrying the binding a supplied secret is about to create. */
10190
function withPendingSecretRef(record: StoredDatasource): StoredDatasource {
10291
return { ...record, external: { ...(record.external ?? {}), credentialsRef: PENDING_SECRET_REF } };
@@ -635,6 +624,14 @@ export class DatasourceAdminService implements IDatasourceAdminService {
635624
}
636625

637626
async testConnection(input: DatasourceDraft, secret?: SecretInput): Promise<TestConnectionResult> {
627+
// [#28612] First, and as a refusal (a `400` at the route) rather than a
628+
// failed probe: the probe below is built from the known keys only, so a
629+
// top-level `password` or any other key the schema refuses would vanish
630+
// and the answer would be about a different connection than the one
631+
// described.
632+
if (input && typeof input === 'object') {
633+
this.assertNoUndeclaredTopLevelKey('This connection draft is not a valid datasource.', input);
634+
}
638635
if (!input?.driver) {
639636
return { ok: false, error: 'A driver is required to test a connection.' };
640637
}
@@ -678,7 +675,7 @@ export class DatasourceAdminService implements IDatasourceAdminService {
678675
async createDatasource(input: DatasourceDraft, secret?: SecretInput): Promise<DatasourceSummary> {
679676
this.assertValidName(input?.name);
680677
this.assertNoDraftCredentialsRef(input);
681-
this.assertNoTopLevelCredential(input.name, input);
678+
this.assertNoUndeclaredTopLevelKey(`Invalid datasource '${input.name}'.`, input);
682679
if (!input.driver) throw new Error('A driver is required to create a datasource.');
683680
this.assertValidConfig(input.driver, input.config);
684681
// The wizard is a publish door for `pool` too (#5714). Rejected BEFORE the
@@ -740,10 +737,10 @@ export class DatasourceAdminService implements IDatasourceAdminService {
740737
if (existing.origin !== 'runtime') {
741738
throw new Error(`Datasource '${name}' is code-defined and cannot be edited at runtime.`);
742739
}
743-
// [#28549] Whatever else the patch touches, a `label`/`active`-only edit
744-
// included: that exemption spares a row stored before a gate, and this
745-
// judges a credential the author is writing now.
746-
this.assertNoTopLevelCredential(existing.name, patch);
740+
// [#28549 · #28612] Whatever else the patch touches, a `label`/`active`-only
741+
// edit included: that exemption spares a row stored before a gate, and this
742+
// judges a key the author is writing now.
743+
this.assertNoUndeclaredTopLevelKey(`Invalid datasource '${existing.name}'.`, patch);
747744

748745
// Merge patch over the existing record; `name`/`origin` are never patched.
749746
const merged: StoredDatasource = {
@@ -1261,23 +1258,29 @@ export class DatasourceAdminService implements IDatasourceAdminService {
12611258
}
12621259

12631260
/**
1264-
* [#28549] Refuse a body that writes a credential at the top level
1265-
* ({@link TOP_LEVEL_CREDENTIAL_KEYS}). The record is built from the known
1266-
* keys only, so such a key would vanish and the write answer success over a
1267-
* datasource that cannot connect, while the build door refuses the same
1268-
* datasource by name.
1261+
* [#28549 · #28612] Refuse a body that carries a top-level key
1262+
* `DatasourceSchema` does not declare: a credential (`password`, a
1263+
* `connectionString` with URL userinfo), a connection key one level too high
1264+
* (`url`, `host`, …), an alias (`type`), a retired block, or any unknown key.
1265+
* The record and the probe are built from the known keys only, so such a key
1266+
* would vanish and the door answer success (or probe) over a datasource
1267+
* other than the one described, while the build door refuses it by name.
1268+
*
1269+
* The key set and the prescription are `DatasourceSchema`'s own: the body's
1270+
* key names, each with `null` for its value, are parsed through the strict
1271+
* shape and only its root `unrecognized_keys` refusal is kept, the one
1272+
* `os compile` prints. A key the schema declares passes here even when the
1273+
* projection leaves it out (`origin`, `description`, …). Only key names are
1274+
* judged, never values, so the answer cannot echo a credential. ⛔ No key
1275+
* list or prescription is restated here; the spec is the one copy. Thrown
1276+
* before anything is read, written or probed (a `400` at the route).
12691277
*
1270-
* The verdict and its prescription are `DatasourceSchema`'s own refusal of
1271-
* those keys, the one `os compile` prints, under the heading
1272-
* {@link assertValidRecord} gives. Only the key names are judged, never the
1273-
* values, so the answer names the key and cannot echo the credential. ⛔ No
1274-
* prescription is restated here; the spec is the one copy. Thrown before
1275-
* anything is read or written (a `400` at the route).
1278+
* `secret` never reaches this check: the route splits it off the body
1279+
* (`splitSecret`) before the service sees the draft, and it stays the one
1280+
* credential channel.
12761281
*/
1277-
private assertNoTopLevelCredential(name: string, body: object): void {
1278-
const present = TOP_LEVEL_CREDENTIAL_KEYS.filter(
1279-
(key) => (body as Record<string, unknown>)[key] !== undefined,
1280-
);
1282+
private assertNoUndeclaredTopLevelKey(heading: string, body: object): void {
1283+
const present = Object.keys(body).filter((key) => (body as Record<string, unknown>)[key] !== undefined);
12811284
if (present.length === 0) return;
12821285
const result = DatasourceSchema.safeParse(Object.fromEntries(present.map((key) => [key, null])));
12831286
const detail = result.success
@@ -1286,7 +1289,7 @@ export class DatasourceAdminService implements IDatasourceAdminService {
12861289
.filter((issue) => issue.code === 'unrecognized_keys' && issue.path.length === 0)
12871290
.map((issue) => issue.message);
12881291
if (detail.length === 0) return;
1289-
throw new Error(`Invalid datasource '${name}'.\n${detail.join('\n')}`);
1292+
throw new Error(`${heading}\n${detail.join('\n')}`);
12901293
}
12911294

12921295
private toRecord(input: DatasourceDraft): StoredDatasource {

0 commit comments

Comments
 (0)