Skip to content

Commit 43f37e1

Browse files
huangyiireneclaude
andauthored
fix(service-datasource): read a turso datasource's bound secret into authToken (#8152) (#8188)
After #8078 a NEW turso datasource could not be authenticated by any route an author has. #7990/#8078 made `config.authToken` a refused inline credential (`z.never()`) at every authoring door, exactly like the SQL drivers' `config.password`, and diverted the author to the secret binder: bind the credential, keep only `external.credentialsRef` on the record. The connect path resolves that ref and hands the cleartext to the driver factory as `spec.secret` — and nothing on the turso path read it. `TURSO_CONFIG_READERS.authToken` consulted `config.authToken` alone, so the resolved secret was dropped and the connection was attempted unauthenticated. `authToken` now reads `spec.secret` first and falls back to `config`. That is exact parity with the postgres / mysql / mongodb arms in this same package (`spec.secret ? { password: spec.secret } : cfg.password ? … : {}`) — no new mechanism, no spec change, no second binder slot. `spec` was already on `TursoConfigSource` for `schemaMode`, so the credential had been reaching this function all along. `config.authToken` stays readable, and not only for legacy stored rows: the CLI and standalone hosts translate `OS_DATABASE_AUTH_TOKEN` / `TURSO_AUTH_TOKEN` into a `config` they construct themselves, which never meets the authoring schema that refuses the key. An empty `spec.secret` is unset and falls through to `config`, matching this builder's existing rule for string keys. Pins, with their readings on origin/main (a0fdc56): RED on main (6) - turso-driver-config: bound secret reaches `authToken`; bound secret beats a stale inline `config.authToken`; binds to `authToken` only, never `encryptionKey`. - turso-bound-secret-authoring (new): a datasource created through the real admin door with a bound secret arrives at the driver with an `authToken`; the bound route is the ONLY one a newly authored datasource has (both halves in one case); the credential lands in one slot, not two. GREEN on main (15) - #8078's inline refusal still fires at create AND at update, and its guidance still names both `external.credentialsRef` and the secret binder. - `config.authToken` is still read when no secret is bound (the host env route). - postgres / mysql / mongodb still read `spec.secret` as the password, and a bound secret still beats an inline `config.password` — the sibling arms this change takes its shape from, unpinned until now. The gap was invisible because a stored row bypasses the parse and still connects (only new authoring was dead) and because `turso-driver-config.test.ts` had no `secret` case at all. Every red pin therefore starts at `createDatasource()`, not at an existing record: the vacuous version of this test is green on main. `encryptionKey` deliberately untouched — a different secret, one binder slot, and whether it needs a second is a separate decision. #8126's read-time redaction of it is untouched too. Gates: check-test-source-alias OK (72 packages scanned, 62 registered; `@objectstack/spec` was already the registry entry for this package, so no widening); check:type-check-coverage OK (64/77 type-checked); the new test file is type-checked (package `tsc --noEmit` clean). No refusal surface changed, so no ADR-0112 envelope. Tests: service-datasource 16/16 files 376/376 cases, runtime 145/145, cli 115/115. Claude-Session: https://claude.ai/code/session_01Lw4Dm3qYuWFNJFMwQDdkzv Co-authored-by: Claude <noreply@anthropic.com>
1 parent c41828d commit 43f37e1

4 files changed

Lines changed: 504 additions & 7 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/service-datasource": patch
3+
---
4+
5+
fix(service-datasource): read a turso datasource's bound secret into `authToken` (#8152)
6+
7+
A turso datasource created after #8078 could not be authenticated by any route
8+
an author has. #7990/#8078 made `config.authToken` a refused inline credential
9+
(`z.never()`) at every authoring door, exactly like the SQL drivers'
10+
`config.password`, and diverted the author to the secret binder:
11+
bind the credential, keep only `external.credentialsRef` on the record. The
12+
connect path resolves that ref and hands the cleartext to the driver factory as
13+
`spec.secret` — and **nothing on the turso path read it**.
14+
`buildTursoDriverConfig` consulted `config.authToken` alone, so the resolved
15+
secret was dropped and the connection was attempted unauthenticated:
16+
17+
```
18+
buildTursoDriverConfig({driver: 'turso', config: {url: 'libsql://my-db.turso.io'},
19+
secret: 'THE-BOUND-JWT', external: {credentialsRef: 'sys_secret:abc'}})
20+
→ { url: 'libsql://my-db.turso.io' } // no authToken
21+
```
22+
23+
`authToken` now reads `spec.secret` first and falls back to `config`, which is
24+
**exact parity with the postgres / mysql / mongodb arms** in the same package
25+
(`spec.secret ? { password: spec.secret } : cfg.password ? { password: cfg.password } : {}`).
26+
No new mechanism, no spec change, no second binder slot: the credential was
27+
already reaching the builder on the spec it is handed, and this restores the
28+
one slot turso already has.
29+
30+
Nothing that worked before changes. `config.authToken` stays readable, and the
31+
fallback matters beyond legacy rows: the CLI and standalone hosts translate
32+
`OS_DATABASE_AUTH_TOKEN` / `TURSO_AUTH_TOKEN` into a `config` they construct
33+
themselves, which never meets the authoring schema that refuses the key. An
34+
empty `spec.secret` is treated as unset and falls through to `config`, matching
35+
this builder's existing rule for string keys.
36+
37+
The gap was invisible because it broke nothing already running — a stored row
38+
bypasses the parse and still connects, so only NEW authoring was dead — and
39+
because `turso-driver-config.test.ts` had no `secret` case at all. It has one
40+
now, plus an end-to-end pin that authors a datasource through the real admin
41+
door, binds the secret, resolves it through the real connect path, and asserts
42+
the credential arrives (`turso-bound-secret-authoring.test.ts`).
43+
44+
#8078's refusal of the inline key is untouched and pinned in both places.
45+
`encryptionKey` is deliberately out of scope: it is a different secret, the
46+
binder has one slot, and whether it needs a second is a separate decision.
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #8152 — a NEW turso datasource authored with a BOUND secret authenticates.
5+
*
6+
* ## The defect, and why nothing caught it
7+
*
8+
* #7990/#8078 closed the inline door: `config.authToken` is `z.never()` at every
9+
* authoring door, exactly like the SQL drivers' `config.password`. The route an
10+
* author is diverted TO — bind the credential, keep only
11+
* `external.credentialsRef` on the record — resolves the cleartext into
12+
* `spec.secret` at connect time. Nothing on the turso path read it. So after
13+
* #8078 a new turso remote datasource had no working credential route at all:
14+
* refused inline, dropped when bound.
15+
*
16+
* It stayed invisible because it broke nothing that already worked. A stored row
17+
* carrying inline `config.authToken` bypasses the parse and still connects, and
18+
* the host boot paths translate `OS_DATABASE_AUTH_TOKEN` / `TURSO_AUTH_TOKEN`
19+
* into a `config` they construct themselves, which never meets the authoring
20+
* schema. Only NEW authoring was dead.
21+
*
22+
* ## What each pin reads on `origin/main` (reverse verification)
23+
*
24+
* ⚠️ The vacuity trap this file is written around: any case that starts from an
25+
* ALREADY-EXISTING turso datasource is green on `main` too, because stored config
26+
* bypasses the parse. Every red pin below therefore starts at
27+
* `createDatasource()` — the authoring door — and carries its credential the only
28+
* way that door permits.
29+
*
30+
* RED on main (carries the defect this card fixes):
31+
* - "a datasource created with a bound secret reaches the driver with an
32+
* authToken" — on main the builder returned `{ url }` for the spec the connect
33+
* path hands it, so the assertion on `authToken` failed. Measured on
34+
* `origin/main`, not inferred.
35+
* - "the bound secret is the ONLY credential a newly authored datasource can
36+
* carry" — pins both halves at once: the door refuses the inline key AND the
37+
* bound one arrives. On main the first half passed and the second failed,
38+
* which is the dead end stated above.
39+
* - "binds the secret to authToken alone, leaving the encryptionKey question
40+
* open" — red on main via its `authToken` half. The `encryptionKey` half is
41+
* forward-facing and is asserted BESIDE that one rather than alone, because
42+
* alone it is green on main for the useless reason (main emitted neither key).
43+
*
44+
* GREEN on main (guards behaviour that must NOT change):
45+
* - "#8078's refusal still fires at create and at update" — guards the spec half.
46+
* This card restores the alternative route; it must not reopen the inline one.
47+
* Passed on main and must keep passing.
48+
* - "the refusal still names both mechanisms it diverts to" — the guidance is
49+
* what makes the refusal actionable, and it now points at a route that
50+
* actually works. Passed on main.
51+
* - "postgres / mysql / mongodb still read `spec.secret` as the password" —
52+
* guards the sibling arms this change takes its shape FROM. Untouched by the
53+
* diff, unpinned before it. Passed on main and must keep passing.
54+
*
55+
* ## The one seam, stated rather than hidden
56+
*
57+
* The factory's real `turso` arm cannot run here: `@objectstack/driver-turso` is
58+
* deliberately not resolvable from this package (that is what "optional" means,
59+
* and the missing-package arm's own pin depends on it). So the red pins capture
60+
* the spec the connect path actually hands `factory.create()` — real authoring
61+
* door, real secret binder, real credential resolution — and run the real
62+
* `buildTursoDriverConfig` on exactly that spec, which is the line the factory
63+
* arm itself executes (`default-datasource-driver-factory.ts`, `kind === 'turso'`).
64+
* Both halves are production code; only the `new TursoDriver(...)` call is absent.
65+
*/
66+
67+
import { describe, it, expect } from 'vitest';
68+
import { validateDriverConfig } from '@objectstack/spec/data';
69+
import {
70+
DatasourceAdminService,
71+
type DatasourceAdminServiceConfig,
72+
type StoredDatasource,
73+
} from '../datasource-admin-service.js';
74+
import {
75+
DatasourceConnectionService,
76+
type ConnectableDatasource,
77+
type ConnectionEngineLike,
78+
} from '../datasource-connection-service.js';
79+
import { buildTursoDriverConfig, resolveTursoUrl } from '../turso-driver-config.js';
80+
import { createDefaultDatasourceDriverFactory } from '../default-datasource-driver-factory.js';
81+
import type {
82+
DatasourceConnectionSpec,
83+
IDatasourceDriverFactory,
84+
} from '../contracts/datasource-driver-factory.js';
85+
86+
const THE_BOUND_JWT = 'eyJhbGciOiJFZERTQSJ9.THE-BOUND-JWT';
87+
const TURSO_URL = 'libsql://my-db.turso.io';
88+
89+
/**
90+
* An admin service over an in-memory record store and an in-memory secret store,
91+
* with the two joined the way a real host joins them: `writeSecret` returns an
92+
* opaque ref and keeps the cleartext where only a resolver can reach it. The
93+
* cleartext must never appear on the record — asserted below rather than assumed.
94+
*/
95+
function makeAuthoringDoor() {
96+
const records: StoredDatasource[] = [];
97+
const secrets = new Map<string, string>();
98+
let n = 0;
99+
const cfg: DatasourceAdminServiceConfig = {
100+
probe: async () => ({ ok: true }),
101+
listDatasourceRecords: async () => records,
102+
getDatasourceRecord: async (name) => records.find((r) => r.name === name),
103+
putDatasourceRecord: async (rec) => {
104+
const i = records.findIndex((r) => r.name === rec.name);
105+
if (i >= 0) records[i] = rec;
106+
else records.push(rec);
107+
},
108+
deleteDatasourceRecord: async () => {},
109+
writeSecret: async (input) => {
110+
const ref = `sys_secret:ds-${++n}`;
111+
secrets.set(ref, input.value);
112+
return ref;
113+
},
114+
countBoundObjects: async () => 0,
115+
};
116+
return { records, secrets, service: new DatasourceAdminService(cfg) };
117+
}
118+
119+
/** The minimum engine the connect path needs to register a driver. */
120+
function stubEngine(): ConnectionEngineLike {
121+
const drivers = new Map<string, { name?: string }>();
122+
return {
123+
registerDriver: (driver: any) => {
124+
drivers.set(driver.name, driver);
125+
},
126+
registerDatasourceDef: () => {},
127+
getDriverByName: (name) => drivers.get(name),
128+
syncObjectSchema: async () => {},
129+
markDatasourceUnavailable: () => {},
130+
clearDatasourceUnavailable: () => {},
131+
} as ConnectionEngineLike;
132+
}
133+
134+
/**
135+
* A factory standing exactly where the real `turso` arm stands, recording the
136+
* spec it is handed. This is the seam described in the header — everything
137+
* upstream of it (door, binder, resolver) is production code.
138+
*/
139+
function capturingFactory() {
140+
const seen: DatasourceConnectionSpec[] = [];
141+
const factory: IDatasourceDriverFactory = {
142+
supports: () => true,
143+
create: async (spec) => {
144+
seen.push(spec);
145+
return { driver: { name: spec.name ?? 'default' } } as never;
146+
},
147+
};
148+
return { seen, factory };
149+
}
150+
151+
/** Author a turso datasource through the real door, then connect it. */
152+
async function authorThenConnect(secretValue: string) {
153+
const { records, secrets, service } = makeAuthoringDoor();
154+
await service.createDatasource(
155+
{ name: 'warehouse', driver: 'turso', schemaMode: 'external', config: { url: TURSO_URL } },
156+
{ value: secretValue },
157+
);
158+
159+
const record = records[0]!;
160+
const { seen, factory } = capturingFactory();
161+
const connection = new DatasourceConnectionService({
162+
factory: () => factory,
163+
engine: () => stubEngine(),
164+
secrets: { resolve: async (ref) => secrets.get(ref) },
165+
});
166+
const result = await connection.connect(record as ConnectableDatasource);
167+
return { record, seen, result };
168+
}
169+
170+
describe('#8152 — the credential route a NEW turso datasource has left', () => {
171+
it('RED ON MAIN — a datasource created with a bound secret reaches the driver with an authToken', async () => {
172+
const { record, seen, result } = await authorThenConnect(THE_BOUND_JWT);
173+
174+
// The door did its job: an opaque ref on the record, no cleartext anywhere in
175+
// it. If this half ever fails the test below is measuring the wrong thing.
176+
expect(result.status).toBe('connected');
177+
expect(record.external?.credentialsRef).toMatch(/^sys_secret:/);
178+
expect(JSON.stringify(record)).not.toContain(THE_BOUND_JWT);
179+
180+
// The connect path resolved the ref and handed the cleartext to the factory…
181+
expect(seen).toHaveLength(1);
182+
const spec = seen[0]!;
183+
expect(spec.secret).toBe(THE_BOUND_JWT);
184+
185+
// …and the builder the turso arm calls puts it in the slot libSQL reads.
186+
// RED on main: this returned `{ url: 'libsql://my-db.turso.io' }`.
187+
expect(buildTursoDriverConfig(spec, resolveTursoUrl(spec))).toMatchObject({
188+
url: TURSO_URL,
189+
authToken: THE_BOUND_JWT,
190+
});
191+
});
192+
193+
it('RED ON MAIN — the bound secret is the ONLY credential a newly authored datasource can carry', async () => {
194+
const { service } = makeAuthoringDoor();
195+
196+
// Half one: the inline key is refused at the door (GREEN on main — #8078).
197+
await expect(
198+
service.createDatasource({
199+
name: 'inline', driver: 'turso',
200+
config: { url: TURSO_URL, authToken: THE_BOUND_JWT },
201+
} as never),
202+
).rejects.toThrow(/is a credential and is not accepted inline/);
203+
204+
// Half two: so the bound route must work, or there is none. RED on main.
205+
const { seen } = await authorThenConnect(THE_BOUND_JWT);
206+
const spec = seen[0]!;
207+
expect(buildTursoDriverConfig(spec, resolveTursoUrl(spec)).authToken).toBe(THE_BOUND_JWT);
208+
});
209+
210+
// ⛔ Step 1 is one slot: the primary credential. `encryptionKey` is a different
211+
// secret (an AES-256 key for a local file, not a bearer token for a remote) and
212+
// whether the binder needs a second slot for it is deliberately undecided —
213+
// #8126's read-time redaction of it grants and removes no slot. This pin fails
214+
// if a later change reads the one bound secret as though it were both.
215+
//
216+
// Both halves asserted together on purpose: `not.toHaveProperty` alone is
217+
// green on main for the useless reason (main emitted neither key), which is
218+
// exactly the vacuity this file is written to avoid. RED on main.
219+
it('RED ON MAIN — binds the secret to authToken alone, leaving the encryptionKey question open', async () => {
220+
const { seen } = await authorThenConnect(THE_BOUND_JWT);
221+
const config = buildTursoDriverConfig(seen[0]!, resolveTursoUrl(seen[0]!));
222+
expect(config.authToken).toBe(THE_BOUND_JWT);
223+
expect(config).not.toHaveProperty('encryptionKey');
224+
});
225+
});
226+
227+
describe('GREEN ON MAIN — #8078 is not reopened by the route this card restores', () => {
228+
it('the inline refusal still fires at create and at update', async () => {
229+
const { service } = makeAuthoringDoor();
230+
await expect(
231+
service.createDatasource({
232+
name: 'inline', driver: 'turso', config: { url: TURSO_URL, authToken: 'jwt' },
233+
} as never),
234+
).rejects.toThrow(/is a credential and is not accepted inline/);
235+
236+
// An existing row is not a licence to type the key back in. `updateDatasource`
237+
// judges the MERGED config, so a patch that reintroduces it is refused too.
238+
await service.createDatasource(
239+
{ name: 'warehouse', driver: 'turso', config: { url: TURSO_URL } },
240+
{ value: THE_BOUND_JWT },
241+
);
242+
await expect(
243+
service.updateDatasource('warehouse', {
244+
config: { url: TURSO_URL, authToken: 'jwt' },
245+
} as never),
246+
).rejects.toThrow(/is a credential and is not accepted inline/);
247+
});
248+
249+
it('the refusal still names both mechanisms it diverts to', () => {
250+
// The guidance is what makes the refusal actionable — and as of this card the
251+
// route it names is one that actually delivers the credential. A refusal that
252+
// said only "not allowed" would leave the author with no next move.
253+
const verdict = validateDriverConfig('turso', { url: TURSO_URL, authToken: 'jwt' });
254+
expect(verdict).toMatchObject({ known: true });
255+
const message = (verdict as { issues: Array<{ message: string }> }).issues[0]!.message;
256+
expect(message).toContain('external.credentialsRef');
257+
expect(message).toContain('secret binder');
258+
});
259+
260+
it('a turso config with no credential at all is still accepted', () => {
261+
// The url-only shape is what a bound datasource stores. It must stay legal:
262+
// if this went red, binding would be unreachable for a different reason.
263+
expect(validateDriverConfig('turso', { url: TURSO_URL })).toEqual({ known: true, issues: [] });
264+
});
265+
});
266+
267+
describe('GREEN ON MAIN — the sibling arms this change takes its shape from are unchanged', () => {
268+
/** The knex config a constructed SqlDriver was built from. */
269+
function knexConfigOf(driver: any): any {
270+
return driver?.config ?? driver?.knexConfig ?? driver?.options ?? {};
271+
}
272+
273+
const factory = () => createDefaultDatasourceDriverFactory({ dev: false });
274+
275+
it('postgres still reads spec.secret as the connection password', async () => {
276+
const handle: any = await factory().create({
277+
driver: 'postgres',
278+
config: { host: 'db.internal', database: 'analytics', username: 'admin' },
279+
secret: 'hunter2',
280+
});
281+
expect(knexConfigOf(handle.driver ?? handle).connection).toMatchObject({ password: 'hunter2' });
282+
try { await handle.disconnect?.(); } catch { /* pool never opened */ }
283+
});
284+
285+
it('postgres still lets the bound secret win over an inline config.password', async () => {
286+
const handle: any = await factory().create({
287+
driver: 'postgres',
288+
config: { host: 'db.internal', database: 'analytics', username: 'admin', password: 'stale' },
289+
secret: 'hunter2',
290+
});
291+
expect(knexConfigOf(handle.driver ?? handle).connection).toMatchObject({ password: 'hunter2' });
292+
try { await handle.disconnect?.(); } catch { /* pool never opened */ }
293+
});
294+
295+
it('mysql still reads spec.secret as the connection password', async () => {
296+
const handle: any = await factory().create({
297+
driver: 'mysql',
298+
config: { host: 'db.internal', database: 'analytics', username: 'admin' },
299+
secret: 'hunter2',
300+
});
301+
expect(knexConfigOf(handle.driver ?? handle).connection).toMatchObject({ password: 'hunter2' });
302+
try { await handle.disconnect?.(); } catch { /* pool never opened */ }
303+
});
304+
305+
it('mongodb still reads spec.secret into the connection url', async () => {
306+
const handle: any = await factory().create({
307+
driver: 'mongodb',
308+
config: { host: 'db.internal', port: 27017, database: 'analytics', username: 'admin' },
309+
secret: 'hunter2',
310+
});
311+
const driver: any = handle.driver ?? handle;
312+
const url = driver?.config?.url ?? driver?.options?.url ?? driver?.url;
313+
expect(url).toBe('mongodb://admin:hunter2@db.internal:27017/analytics');
314+
try { await handle.disconnect?.(); } catch { /* client never opened */ }
315+
});
316+
});

0 commit comments

Comments
 (0)