Skip to content

Commit dd6349e

Browse files
fix(service-datasource)!: the datasource admin doors store ssl, autoConnect and description instead of dropping them, and refuse an inline ssl.key (#28648)
domain:server#3 · os-project-manager · session_01FEkQ5kE5BrPUPNMMyv6oCH <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XGrPzJx6uT5fAt6Ykpfm9uJ)_ Part of #28633 (the card stays open for its read-path follow-up, which closes it) Clause-②: yes (narrowing) — measured, not the claim's expected `no`: `POST /api/v1/datasources` and `PATCH /api/v1/datasources/NAME` now store `ssl`, `autoConnect` and `description` (accepted and dropped before), `GET /api/v1/datasources/NAME` serves them (three new answer members), `POST /api/v1/datasources/test` hands the draft's `ssl` to the probe, and the exported `DatasourceDraft`, `StoredDatasource`, `ProbeInput` and `ConnectivityBearingFields` types gain those members. The `(narrowing)` arm: one body that used to answer 201 / 200 (its whole `ssl` block silently dropped) is now refused with `400 DATASOURCE_ADMIN_ERROR`: an `ssl` block that carries a client private key (`ssl.key`). No new error code. Metadata-protocol: none Skills-docs: updated here — `content/docs/data-modeling/drivers.mdx` (the TLS bullet says the Setup doors store the block and refuse `key`); no page under `skills/**` teaches the admin request body. **BREAKING** accept-set narrowing at the datasource admin doors (`@objectstack/service-datasource`): an inline `ssl.key` is refused. **Built on PR #28632** (same file, landed as `819b1017`): this branch started from its head `f1438f52` and has merged `origin/main` since, so the diff is only this change. ## 维护者速读 - **改了什么**:数据源管理接口的创建、编辑和连接测试,现在保留 `DatasourceSchema` 声明的所有顶层键(`ssl`、`autoConnect`、`description` 以前被悄悄丢掉)。保留哪些键在运行时直接从 schema 读出,不再有手写清单;`origin` 和 ADR-0010 保护字段(`_lock*` 等)仍由服务端决定,不从请求体取。连接测试用的是保存时会存的同一份记录,所以测试也走同样的 TLS。编辑 `ssl` 会重建连接。 - **为什么改**:卡 #28633:带 TLS 设置创建数据源,接口回 201,但存下来的记录没有 `ssl`,实际连接不加密,也没有任何提示。 - **风险与回滚**:新增一个拒收:`ssl.key`(客户端私钥)在三个入口都被拒(400,点名不回显值),因为这个入口没有加密存放它的通道,存下来就是明文。这是一个需要您确认的取舍,见下方 Acceptance notes 第一条。读取时永不返回已存的私钥;编辑时原样回传 `ssl` 会保留已存的私钥。控制台、`packages/client`、CLI 都不发送这三个键。回滚即 revert。 - **先看**:`packages/services/service-datasource/src/datasource-admin-service.ts`(`storedDatasourceKeys`、`toRecord`、编辑合并、`inlineSslKeyRefusal`、`getDatasource`);`src/__tests__/datasource-admin-declared-keys-kept.test.ts`;`src/datasource-admin-plugin.ts`(探测把 `ssl` 交给驱动工厂)。 - **两分钟验证**:`pnpm --filter @objectstack/service-datasource exec vitest run src/__tests__/datasource-admin-declared-keys-kept.test.ts` → 18/18 通过。 - **需要你做的**:决定 `ssl.key` 的处理(推荐:先拒收,等有加密通道再开放);其余席位落地(收窄需先过合约评审)。 ## What changes **One projection, derived from the schema** (`datasource-admin-service.ts`). The hand list in `toRecord` (`name`, `label`, `driver`, `schemaMode`, `config`, `external`, `pool`, `active`) becomes `storedDatasourceKeys()`: every key of `DatasourceSchema.shape` read at run time, minus the keys the door deliberately does not take from the body, each named with its reason: - `origin` — the schema's own words: "server-managed, read-only", "never accepted from client input". The door stamps `runtime`, as before. - the ADR-0010 protection envelope, read off `MetadataProtectionFields` (`_lock`, `_lockReason`, `_lockSource`, `_lockDocsUrl`, `_provenance`, `_packageId`, `_packageVersion`) — set by a code package's loader only. The metadata save door refuses an authored lock for exactly this reason (ADR-0010 §3.5, `authoredLockFamilyRefusal`), so storing them here would be the way around that door. The seat asked to include them if the schema derivation covers them; it does, and this is the reason they are left out. So the stored set at this head is `name`, `label`, `driver`, `config`, `pool`, `ssl`, `description`, `active`, `autoConnect`, `schemaMode`, `external`. A key the schema declares later is stored the day it lands. - **Create** stores `toRecord(input)`. **Edit** merges every patched key under the same set (`name` never, `external` keeps its `credentialsRef` rule). The record judgement on an edit now runs whenever the patch touches a key other than `label` / `active` (the two documented exemptions), so an `ssl` edit is judged by the schema's strict `ssl` shape before it is stored; a `description` / `autoConnect` edit is judged too. - **Test door** builds one `draft = toRecord(...)` and both judges and probes it: `ProbeInput` gains `ssl`, and the plugin's probe hands it to `factory.create` (`datasource-admin-plugin.ts`), as the saved datasource's connect does through `toSpec` (`ssl: record.ssl`). - **Live connection.** `datasourceConnectivityChanged` now compares `ssl` (`toSpec` reads it), so an edit that changes the TLS block rebuilds the pool; a `description` edit does not. - **Read.** `getDatasource` serves `ssl` (without `key`), `autoConnect` and `description`. - **`ssl.key`.** `inlineSslKeyRefusal` refuses a body whose `ssl` carries `key`, on create and edit (throw, `400 DATASOURCE_ADMIN_ERROR`, under the door's existing headings) and on the test door (`ok: false`, like its other record refusals, nothing probed). The message names `ssl.key`, never the value. A stored key (a row written by another door) is withheld on read, and an edit that sends back the served block keeps it (`restoreRedactedConfig`'s rule, applied to the one withheld member). ## Tests New `src/__tests__/datasource-admin-declared-keys-kept.test.ts`, same harness as #28607 / #28632 (real routes over `HonoHttpServer`, real `DatasourceAdminService` on in-memory stores, `entitled-caller.fixture`), recording the stored record, the record handed to `registerPool` / `reregisterPool`, and the probe input; one case drives the real `DatasourceAdminServicePlugin` probe over a fake driver factory and reads the spec handed to `factory.create`. - **Create**: `ssl` + `autoConnect` + `description` → 201, all three stored, the record handed to `registerPool` carries `ssl`; a `GET` returns them; an unknown `ssl` member (`insecure`) is refused (400 `DATASOURCE_ADMIN_ERROR`, nothing stored). - **Edit**: `ssl` stored and the pool rebuilt from a record carrying it; `autoConnect` / `description` stored. - **Test door**: the probe receives the draft's `ssl`; the plugin probe hands it to the driver factory. - **`ssl.key`**: create, edit and test refuse it (status + `error.code` + `success`, or `result.ok: false`; the value absent from the raw answer; nothing stored, minted, opened or probed); a `GET` of a row already holding a key does not serve it; an edit sending back the served block keeps the stored key. - **Controls**: a draft without these keys stores exactly `name`, `driver`, `config`, `origin` as before; `origin` is stamped `runtime` and no protection field is stored; a `description`-only edit does not rebuild the pool; a `label`-only edit leaves a stored `ssl` alone; a test draft without `ssl` probes without it. #28632's and #28607's suites (25 cases) stay green. <details><summary>Red first, green, ablation</summary> - **Red first** (production sources restored to `f1438f52`, #28632's head, test file kept): `Tests 12 failed | 6 passed (18)`. The six passing are the controls. The "read withholds a stored key" case is red at the base because the read serves no `ssl` at all, so it pins the served block, not a leak at the base. - **Green** at the fix: `Tests 18 passed (18)`. - **Ablation** (`node scripts/ablation-replace.mjs`, the new file plus #28632's file, 43 cases; the tests import the service and plugin from `src/`, so no build sits between mutation and run), each restore proven `blob == HEAD` and `git diff HEAD` empty: - A1 `toRecord` back to the hand list: 6 failed (create/read/ssl-judged, both probe cases, test-door key refusal). - A2 edit merge back to the old projection: 3 failed (ssl edit, autoConnect/description edit, key carry-forward). - A3 / A3b / A3c the `ssl.key` refusal removed on create / edit / test: 1 failed each, the matching refusal. - A4 the read serves the stored `ssl` unfiltered: 1 failed (the withheld key). - A5 the plugin probe stops handing `ssl` to the factory: 1 failed. - A6 `ssl` out of `datasourceConnectivityChanged`: 1 failed (the edit no longer rebuilds). - A7 the key carry-forward removed: 1 failed. - A8 the protection envelope taken off the server-stamped set: 1 failed. </details> ## Acceptance notes - **Decision: `ssl.key`** (also in the os-dev-report's `open_questions`). The card asks that a secret inside `ssl` be "redacted on read, as `config` secrets are today". Measured: this door does not store `config` secrets at all (the driver contracts refuse `config.password`; the credential goes through `secret` into the secret store and only the `credentialsRef` is stored), and the platform's datasource redaction covers `config`, not `ssl`. Storing `ssl.key` would put a private key at rest in cleartext in the datasource row, outside every redaction this package controls. This PR takes the conservative arm: refuse it at all three doors, withhold it on this door's read. Accepting it would need a spec-side change first (redaction of `ssl.key` wherever datasource rows are read, or a sealed channel for it), which is the spec lane's; the seat has the detail. - **The protection envelope is still dropped, not refused.** Left out of the stored set by design (above). Refusing an authored lock family, as the `/meta` save door does, would be a further narrowing of this door, so it is reported rather than done here. - **The probe still omits `pool` and `schemaMode`** from the spec it hands the factory, as before; neither changes whether or how the connection is encrypted, and the card asks for `ssl`. - **Producers measured** (order's stop-and-report check): none relies on the drop. The Console form (`ui/packages/app-shell/src/views/metadata-admin/datasource/`) sends `label`, `driver`, `schemaMode`, `config`, `external`, `secret` and never these three keys; `packages/client` has no create/update/test method; the CLI datasource commands are read-only. - **The #28612 changeset** names `description`, `ssl` and `autoConnect` among the keys "the doors do not store"; it is not edited, and this PR's changeset carries the superseding sentence. ## Verification All readings on head `1599499f` (the merge of `origin/main` at `68a2a216`, after #28632 landed as `819b1017`; the diff against `main` is the seven files of this change). - **Package:** `pnpm --filter @objectstack/service-datasource test` → Test Files 68 passed (68), Tests 1143 passed (1143). `pnpm --filter @objectstack/service-datasource typecheck` exit 0; `tsc --listFiles` includes the new test file. - **Derived gate families:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` → 91 families (61 before the docs line; the `drivers.mdx` edit adds the docs families), each run after a full `pnpm build --concurrency=2` (117/117) with its exit code captured before any pipe and stdin from `/dev/null`: 91 × exit 0. `check-plugin-teardown-shape.mjs --self-test` first answered exit 3 (shallow clone, fixture commit absent) and exit 0 after `git fetch --depth=1` of that commit. `--ran`: `✓ dispatch-gates --ran: 91 derived famil(ies) accounted for — 91 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 (1 anchored file, 2 anchors judged, 0 lost); `pnpm exec eslint --no-inline-config packages/services/service-datasource` exit 0, no findings; control-byte scan of the changed files: no hits. 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 | 366 passed | 3 skipped (370), Tests 1 failed | 2965 passed | 28 skipped (2994). The one failure is not this change and is the same one #28632 recorded: `route-ledger-live-mount-parity.dogfood.test.ts` reports `runtime/route-ledger.ts: GET /packages/:id/customizations — LEDGERED BUT NOT MOUNTED`, a runtime route from #28595 (`519089e1`) on `main`. This diff touches only `service-datasource` and one docs page. - **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_01FEkQ5oV7AGmgEqUAMoY7y9 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f23e051 commit dd6349e

7 files changed

Lines changed: 523 additions & 25 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/service-datasource': patch
3+
---
4+
5+
fix(service-datasource)!: the datasource admin doors store `ssl`, `autoConnect` and `description` instead of dropping them, and the connection test probes the same TLS block a Save stores
6+
7+
Clause-②: yes (narrowing) — `POST /api/v1/datasources` and `PATCH /api/v1/datasources/:name` now store every top-level key `DatasourceSchema` declares (`ssl`, `autoConnect` and `description` were accepted and dropped), `GET /api/v1/datasources/:name` serves them, `POST /api/v1/datasources/test` hands the draft's `ssl` to the probe, and the exported `DatasourceDraft`, `StoredDatasource`, `ProbeInput` and `ConnectivityBearingFields` types gain those members. One body that used to answer 201 / 200 (with the whole `ssl` block dropped) is now refused with `400 DATASOURCE_ADMIN_ERROR`: an `ssl` block carrying a client private key (`ssl.key`).
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: the doors now store keys DatasourceSchema already declares. The one refused body (an inline ssl.key) was never stored, because the doors dropped the whole ssl block, 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`): an inline `ssl.key` is refused.
13+
14+
- **Before.** Create and edit built the stored record from a hand list of eight keys (`name`, `label`, `driver`, `schemaMode`, `config`, `external`, `pool`, `active`). `ssl`, `autoConnect` and `description` passed the undeclared-key check (the schema declares them) and were then dropped, so a datasource saved with a TLS block was stored, and connected, without one. The connection test probed the same short record.
15+
- **Now.** The stored set is read from `DatasourceSchema`'s shape at run time, so a key the schema declares later is stored the day it lands. Create, edit and the test probe use that one projection; an edit that changes `ssl` rebuilds the live connection. A read serves `ssl`, `autoConnect` and `description`.
16+
- **Not taken from the body**, by design: `origin` (stamped `runtime`) and the ADR-0010 protection envelope (`_lock*`, `_provenance`, `_packageId`, `_packageVersion`), which only a code package's loader sets.
17+
- **`ssl.key`.** The client private key has no sealed channel at this door (a `secret` is the only one, and it takes no TLS key), so storing it would put key material in the datasource row in cleartext. All three doors refuse it by name, never echoing the value; `enabled`, `rejectUnauthorized`, `ca` and `cert` are stored. A read never serves a key a stored row already holds, and an edit that sends back the served `ssl` block keeps that stored key.
18+
- This supersedes the line of the #28612 entry that names `description`, `ssl` and `autoConnect` among the keys "the doors do not store".
19+
20+
**The remedy.** Leave `ssl.key` out of the request; the rest of the TLS block is honoured.

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,11 @@ Two things live **outside** `config`, because they are not driver-specific:
154154
delete the rejected keys; they were reaching nothing either way.
155155
- **TLS certificates** — the `ssl` block on the datasource (`enabled`,
156156
`rejectUnauthorized`, `ca`, `cert`, `key`). Inside `config`, `ssl` is the
157-
on/off boolean shorthand.
157+
on/off boolean shorthand. A datasource created or edited in Setup
158+
(`POST` / `PATCH /api/v1/datasources`) stores the block, and its connection
159+
test probes with it, but `key` is refused there: that door seals only the
160+
`secret` it is sent, so a client private key would sit in the stored row in
161+
cleartext.
158162
- **`schemaMode`** — the ADR-0015 ownership mode, declared next to `driver`.
159163

160164
A plugin-contributed driver (`com.vendor.snowflake`) has no contract in this
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi } from 'vitest';
4+
import { DatasourceSchema } from '@objectstack/spec/data';
5+
import { MetadataProtectionFields } from '@objectstack/spec/kernel';
6+
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
7+
import { registerDatasourceAdminRoutes } from '../admin-routes.js';
8+
import {
9+
DatasourceAdminService,
10+
type DatasourceAdminServiceConfig,
11+
type ProbeInput,
12+
type StoredDatasource,
13+
} from '../datasource-admin-service.js';
14+
import { DatasourceAdminServicePlugin } from '../datasource-admin-plugin.js';
15+
import type {
16+
DatasourceConnectionSpec,
17+
IDatasourceAdminService,
18+
IDatasourceDriverFactory,
19+
} from '../contracts/index.js';
20+
import {
21+
ENTITLED_CREDENTIAL,
22+
createSessionAuthService,
23+
createGrantsEngine,
24+
} from './entitled-caller.fixture.js';
25+
26+
/**
27+
* #28633 — the datasource admin doors keep every top-level key
28+
* `DatasourceSchema` declares, `ssl`, `autoConnect` and `description` among
29+
* them.
30+
*
31+
* `POST /api/v1/datasources` and `PATCH /api/v1/datasources/:name` built the
32+
* stored record from a hand list of eight keys, so these three passed the
33+
* undeclared-key refusal (the schema declares them) and were then dropped: a
34+
* datasource saved with a TLS block was stored, and connected, without one.
35+
* `POST /api/v1/datasources/test` probed the same short projection.
36+
*
37+
* Driven through the REAL routes over the REAL service on in-memory stores,
38+
* reading the stored record, the record the pool seam is handed, the probe
39+
* input and the read answer. The plugin's own probe is read through a fake
40+
* driver factory, for the hand-over to the driver.
41+
*
42+
* Every certificate and key below is an obvious placeholder.
43+
*/
44+
45+
const authService = createSessionAuthService();
46+
const grantsEngine = createGrantsEngine();
47+
48+
const PLACEHOLDER_CA = 'PLACEHOLDER_CA_CERTIFICATE';
49+
const PLACEHOLDER_KEY = 'PLACEHOLDER_NOT_A_PRIVATE_KEY';
50+
const SSL = { enabled: true, rejectUnauthorized: true, ca: PLACEHOLDER_CA };
51+
const BASE = { driver: 'postgres', config: { host: 'db.example.invalid', database: 'analytics' } };
52+
53+
/** In-memory stores behind a real {@link DatasourceAdminService}, recording every seam call. */
54+
function makeDoor(seed: StoredDatasource[] = []) {
55+
const records = new Map<string, StoredDatasource>(seed.map((r) => [r.name, structuredClone(r)]));
56+
const ops: string[] = [];
57+
const pooled: StoredDatasource[] = [];
58+
const probes: ProbeInput[] = [];
59+
60+
const config: DatasourceAdminServiceConfig = {
61+
probe: async (input) => {
62+
ops.push('probe');
63+
probes.push(structuredClone(input));
64+
return { ok: true, latencyMs: 1 };
65+
},
66+
listDatasourceRecords: async () => [...records.values()].map((r) => structuredClone(r)),
67+
getDatasourceRecord: async (name) => {
68+
const r = records.get(name);
69+
return r ? structuredClone(r) : undefined;
70+
},
71+
putDatasourceRecord: async (record) => {
72+
ops.push(`put:${record.name}`);
73+
records.set(record.name, structuredClone(record));
74+
},
75+
deleteDatasourceRecord: async (name) => {
76+
records.delete(name);
77+
},
78+
writeSecret: async (_input, hint) => {
79+
ops.push('writeSecret');
80+
return `sys_secret:sec_${hint.name}`;
81+
},
82+
countBoundObjects: async () => 0,
83+
listObjectsBoundTo: async () => [],
84+
registerPool: (record) => {
85+
ops.push('registerPool');
86+
pooled.push(structuredClone(record));
87+
},
88+
unregisterPool: () => {
89+
ops.push('unregisterPool');
90+
},
91+
reregisterPool: (_previous, next) => {
92+
ops.push('reregisterPool');
93+
pooled.push(structuredClone(next));
94+
},
95+
};
96+
97+
const service = new DatasourceAdminService(config);
98+
const server = new HonoHttpServer(0);
99+
const ctx = {
100+
getService: vi.fn((name: string) =>
101+
name === 'auth'
102+
? authService
103+
: name === 'objectql' || name === 'data'
104+
? grantsEngine
105+
: name === 'datasource-admin'
106+
? service
107+
: undefined,
108+
),
109+
} as any;
110+
registerDatasourceAdminRoutes(server, ctx, '/api/v1');
111+
const app = server.getRawApp();
112+
113+
const send = (method: string, path: string, body?: unknown) =>
114+
app.fetch(
115+
new Request(`http://local/api/v1${path}`, {
116+
method,
117+
headers: { 'content-type': 'application/json', authorization: ENTITLED_CREDENTIAL },
118+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
119+
}),
120+
);
121+
122+
return { send, records, ops, pooled, probes };
123+
}
124+
125+
const STORED: StoredDatasource = { name: 'wh', ...BASE, origin: 'runtime' };
126+
127+
async function expectRefusal(res: Response): Promise<string> {
128+
const raw = await res.text();
129+
const body = JSON.parse(raw) as { success: boolean; error?: { code: string; message: string } };
130+
expect({ status: res.status, success: body.success, code: body.error?.code }).toEqual({
131+
status: 400,
132+
success: false,
133+
code: 'DATASOURCE_ADMIN_ERROR',
134+
});
135+
// Named, never echoed.
136+
expect(raw).not.toContain(PLACEHOLDER_KEY);
137+
return body.error!.message;
138+
}
139+
140+
describe('#28633 premise — the keys this card is about are declared by DatasourceSchema', () => {
141+
it('declares ssl, autoConnect and description (so the undeclared-key refusal lets them through)', () => {
142+
const shape = Object.keys((DatasourceSchema as unknown as { shape: object }).shape);
143+
expect(shape).toEqual(expect.arrayContaining(['ssl', 'autoConnect', 'description']));
144+
});
145+
});
146+
147+
describe('POST /api/v1/datasources — create keeps every declared key (#28633)', () => {
148+
it('stores ssl, autoConnect and description, and the pool is opened from a record carrying ssl', async () => {
149+
const { send, records, pooled } = makeDoor();
150+
const res = await send('POST', '/datasources', {
151+
name: 'wh',
152+
...BASE,
153+
ssl: SSL,
154+
autoConnect: true,
155+
description: 'Analytics warehouse',
156+
});
157+
expect(res.status).toBe(201);
158+
expect(records.get('wh')).toMatchObject({ ssl: SSL, autoConnect: true, description: 'Analytics warehouse' });
159+
expect(pooled).toHaveLength(1);
160+
expect(pooled[0].ssl).toEqual(SSL);
161+
});
162+
163+
it('a read returns them', async () => {
164+
const { send } = makeDoor();
165+
await send('POST', '/datasources', { name: 'wh', ...BASE, ssl: SSL, autoConnect: true, description: 'd' });
166+
const res = await send('GET', '/datasources/wh');
167+
expect(res.status).toBe(200);
168+
const body = (await res.json()) as { data: { datasource: Record<string, unknown> } };
169+
expect(body.data.datasource).toMatchObject({ ssl: SSL, autoConnect: true, description: 'd' });
170+
});
171+
172+
it('a stored TLS block is still judged by the schema: an unknown ssl key is refused, nothing stored', async () => {
173+
const { send, records, ops } = makeDoor();
174+
const message = await expectRefusal(
175+
await send('POST', '/datasources', { name: 'wh', ...BASE, ssl: { enabled: true, insecure: true } }),
176+
);
177+
expect(message).toContain('ssl');
178+
expect(records.has('wh')).toBe(false);
179+
expect(ops).toEqual([]);
180+
});
181+
182+
it('control: a draft without these keys stores none of them, as before', async () => {
183+
const { send, records } = makeDoor();
184+
expect((await send('POST', '/datasources', { name: 'wh', ...BASE })).status).toBe(201);
185+
const stored = records.get('wh')!;
186+
expect(Object.keys(stored).sort()).toEqual(['config', 'driver', 'name', 'origin']);
187+
});
188+
189+
it('server-stamped keys are not taken from the body: origin is stamped runtime, the protection envelope is not stored', async () => {
190+
const { send, records } = makeDoor();
191+
const res = await send('POST', '/datasources', {
192+
name: 'wh',
193+
...BASE,
194+
origin: 'code',
195+
_lock: 'full',
196+
_lockReason: 'locked by the author',
197+
_provenance: 'package',
198+
_packageId: 'com.example.pkg',
199+
});
200+
expect(res.status).toBe(201);
201+
const stored = records.get('wh')!;
202+
expect(stored.origin).toBe('runtime');
203+
for (const key of Object.keys(MetadataProtectionFields)) expect(stored).not.toHaveProperty(key);
204+
});
205+
});
206+
207+
describe('PATCH /api/v1/datasources/:name — edit keeps every declared key (#28633)', () => {
208+
it('stores a new ssl block and rebuilds the pool from a record carrying it', async () => {
209+
const { send, records, ops, pooled } = makeDoor([STORED]);
210+
const res = await send('PATCH', '/datasources/wh', { ssl: SSL });
211+
expect(res.status).toBe(200);
212+
expect(records.get('wh')?.ssl).toEqual(SSL);
213+
expect(ops).toContain('reregisterPool');
214+
expect(pooled[pooled.length - 1]?.ssl).toEqual(SSL);
215+
});
216+
217+
it('stores autoConnect and description', async () => {
218+
const { send, records } = makeDoor([STORED]);
219+
expect((await send('PATCH', '/datasources/wh', { autoConnect: true, description: 'Moved' })).status).toBe(200);
220+
expect(records.get('wh')).toMatchObject({ autoConnect: true, description: 'Moved' });
221+
});
222+
223+
it('control: a description-only edit does not churn the live connection', async () => {
224+
const { send, ops } = makeDoor([STORED]);
225+
expect((await send('PATCH', '/datasources/wh', { description: 'Moved' })).status).toBe(200);
226+
expect(ops).toContain('registerPool');
227+
expect(ops).not.toContain('reregisterPool');
228+
});
229+
230+
it('control: a label-only edit leaves a stored ssl block as it was', async () => {
231+
const { send, records } = makeDoor([{ ...STORED, ssl: SSL }]);
232+
expect((await send('PATCH', '/datasources/wh', { label: 'Warehouse' })).status).toBe(200);
233+
expect(records.get('wh')?.ssl).toEqual(SSL);
234+
});
235+
});
236+
237+
describe('POST /api/v1/datasources/test — the probe reads the record a Save stores (#28633)', () => {
238+
it('hands the draft ssl block to the probe', async () => {
239+
const { send, probes } = makeDoor();
240+
const res = await send('POST', '/datasources/test', { ...BASE, ssl: SSL });
241+
expect(res.status).toBe(200);
242+
expect(probes).toHaveLength(1);
243+
expect(probes[0].ssl).toEqual(SSL);
244+
});
245+
246+
it('control: a draft without ssl probes without it, as before', async () => {
247+
const { send, probes } = makeDoor();
248+
expect((await send('POST', '/datasources/test', BASE)).status).toBe(200);
249+
expect(probes[0]).not.toHaveProperty('ssl');
250+
});
251+
252+
it('the plugin probe hands ssl to the driver factory', async () => {
253+
const created: DatasourceConnectionSpec[] = [];
254+
const factory: IDatasourceDriverFactory = {
255+
supports: () => true,
256+
create: async (spec) => {
257+
created.push(structuredClone(spec));
258+
return { connect: async () => {}, ping: async () => {}, disconnect: async () => {} } as any;
259+
},
260+
} as IDatasourceDriverFactory;
261+
let admin: IDatasourceAdminService | undefined;
262+
const ctx: any = {
263+
getService: (name: string) => {
264+
if (name === 'metadata') return { get: async () => undefined, list: async () => [] };
265+
throw new Error(`no service ${name}`);
266+
},
267+
registerService: (name: string, svc: unknown) => {
268+
if (name === 'datasource-admin') admin = svc as IDatasourceAdminService;
269+
},
270+
trigger: async () => {},
271+
logger: { warn() {}, info() {} },
272+
};
273+
await new DatasourceAdminServicePlugin({ driverFactory: factory }).init(ctx);
274+
const result = await admin!.testConnection({ name: 'x', ...BASE, ssl: SSL });
275+
expect(result.ok).toBe(true);
276+
expect(created).toHaveLength(1);
277+
expect(created[0].ssl).toEqual(SSL);
278+
});
279+
});
280+
281+
describe('ssl.key — a TLS client private key is never stored in cleartext or served (#28633)', () => {
282+
it('create refuses an inline ssl.key by name: nothing stored, minted or opened', async () => {
283+
const { send, records, ops } = makeDoor();
284+
const message = await expectRefusal(
285+
await send('POST', '/datasources', { name: 'wh', ...BASE, ssl: { ...SSL, key: PLACEHOLDER_KEY } }),
286+
);
287+
expect(message.split('\n')[0]).toBe("Invalid datasource 'wh'.");
288+
expect(message).toContain('`ssl.key`');
289+
expect(records.has('wh')).toBe(false);
290+
expect(ops).toEqual([]);
291+
});
292+
293+
it('edit refuses an inline ssl.key by name: the stored record is unchanged', async () => {
294+
const { send, records, ops } = makeDoor([STORED]);
295+
const message = await expectRefusal(
296+
await send('PATCH', '/datasources/wh', { ssl: { ...SSL, key: PLACEHOLDER_KEY } }),
297+
);
298+
expect(message).toContain('`ssl.key`');
299+
expect(records.get('wh')).toEqual(STORED);
300+
expect(ops).toEqual([]);
301+
});
302+
303+
it('the test door refuses it the same way, as a failed probe that never ran', async () => {
304+
const { send, probes } = makeDoor();
305+
const res = await send('POST', '/datasources/test', { ...BASE, ssl: { ...SSL, key: PLACEHOLDER_KEY } });
306+
const raw = await res.text();
307+
expect(raw).not.toContain(PLACEHOLDER_KEY);
308+
const body = JSON.parse(raw) as { data: { result: { ok: boolean; error: string } } };
309+
expect(res.status).toBe(200);
310+
expect(body.data.result.ok).toBe(false);
311+
expect(body.data.result.error).toContain('`ssl.key`');
312+
expect(probes).toEqual([]);
313+
});
314+
315+
it('a read withholds a private key a stored row already holds', async () => {
316+
const { send } = makeDoor([{ ...STORED, ssl: { ...SSL, key: PLACEHOLDER_KEY } }]);
317+
const res = await send('GET', '/datasources/wh');
318+
const raw = await res.text();
319+
expect(res.status).toBe(200);
320+
expect(raw).not.toContain(PLACEHOLDER_KEY);
321+
const body = JSON.parse(raw) as { data: { datasource: { ssl: Record<string, unknown> } } };
322+
expect(body.data.datasource.ssl).toEqual(SSL);
323+
});
324+
325+
it('an edit sending back the served ssl block keeps the stored key', async () => {
326+
const { send, records } = makeDoor([{ ...STORED, ssl: { ...SSL, key: PLACEHOLDER_KEY } }]);
327+
expect((await send('PATCH', '/datasources/wh', { ssl: { ...SSL, rejectUnauthorized: false } })).status).toBe(200);
328+
expect(records.get('wh')?.ssl).toEqual({ ...SSL, rejectUnauthorized: false, key: PLACEHOLDER_KEY });
329+
});
330+
});

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,16 @@ export interface DatasourceDraft {
5252
external?: Record<string, unknown>;
5353
pool?: Record<string, unknown>;
5454
active?: boolean;
55+
/**
56+
* TLS block (`DatasourceSchema.ssl`): `enabled`, `rejectUnauthorized`, `ca`,
57+
* `cert`. Stored and probed as sent (#28633); a client private key
58+
* (`ssl.key`) is refused, since it would be stored in cleartext.
59+
*/
60+
ssl?: Record<string, unknown>;
61+
/** Force a live connection at boot even when managed + unrouted (ADR-0062 D2(c)). */
62+
autoConnect?: boolean;
63+
/** Internal description. */
64+
description?: string;
5565
}
5666

5767
/** Result of probing a connection (live driver connect + cheap round-trip). */

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -966,6 +966,10 @@ export class DatasourceAdminServicePlugin implements Plugin {
966966
config: input.config,
967967
secret: input.secret,
968968
external: input.external,
969+
// [#28633] The draft's TLS block, as the saved datasource's connect
970+
// hands it over (`toSpec`): a probe without it tested a plain-text
971+
// connection to a server the save would reach over TLS.
972+
...(input.ssl !== undefined ? { ssl: input.ssl } : {}),
969973
...(credential?.ok ? { credential: credential.handle } : {}),
970974
});
971975
} catch (err) {

0 commit comments

Comments
 (0)