diff --git a/.changeset/turso-bound-secret-authtoken.md b/.changeset/turso-bound-secret-authtoken.md new file mode 100644 index 0000000000..3d4de9ed24 --- /dev/null +++ b/.changeset/turso-bound-secret-authtoken.md @@ -0,0 +1,46 @@ +--- +"@objectstack/service-datasource": patch +--- + +fix(service-datasource): read a turso datasource's bound secret into `authToken` (#8152) + +A turso datasource created after #8078 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**. +`buildTursoDriverConfig` consulted `config.authToken` alone, so the resolved +secret was dropped and the connection was attempted unauthenticated: + +``` +buildTursoDriverConfig({driver: 'turso', config: {url: 'libsql://my-db.turso.io'}, + secret: 'THE-BOUND-JWT', external: {credentialsRef: 'sys_secret:abc'}}) + → { url: 'libsql://my-db.turso.io' } // no authToken +``` + +`authToken` now reads `spec.secret` first and falls back to `config`, which is +**exact parity with the postgres / mysql / mongodb arms** in the same package +(`spec.secret ? { password: spec.secret } : cfg.password ? { password: cfg.password } : {}`). +No new mechanism, no spec change, no second binder slot: the credential was +already reaching the builder on the spec it is handed, and this restores the +one slot turso already has. + +Nothing that worked before changes. `config.authToken` stays readable, and the +fallback matters beyond legacy 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 treated as unset and falls through to `config`, matching +this builder's existing rule for string keys. + +The gap was invisible because it broke nothing already running — a stored row +bypasses the parse and still connects, so only NEW authoring was dead — and +because `turso-driver-config.test.ts` had no `secret` case at all. It has one +now, plus an end-to-end pin that authors a datasource through the real admin +door, binds the secret, resolves it through the real connect path, and asserts +the credential arrives (`turso-bound-secret-authoring.test.ts`). + +#8078's refusal of the inline key is untouched and pinned in both places. +`encryptionKey` is deliberately out of scope: it is a different secret, the +binder has one slot, and whether it needs a second is a separate decision. diff --git a/packages/services/service-datasource/src/__tests__/turso-bound-secret-authoring.test.ts b/packages/services/service-datasource/src/__tests__/turso-bound-secret-authoring.test.ts new file mode 100644 index 0000000000..9d69beebce --- /dev/null +++ b/packages/services/service-datasource/src/__tests__/turso-bound-secret-authoring.test.ts @@ -0,0 +1,316 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #8152 — a NEW turso datasource authored with a BOUND secret authenticates. + * + * ## The defect, and why nothing caught it + * + * #7990/#8078 closed the inline door: `config.authToken` is `z.never()` at every + * authoring door, exactly like the SQL drivers' `config.password`. The route an + * author is diverted TO — bind the credential, keep only + * `external.credentialsRef` on the record — resolves the cleartext into + * `spec.secret` at connect time. Nothing on the turso path read it. So after + * #8078 a new turso remote datasource had no working credential route at all: + * refused inline, dropped when bound. + * + * It stayed invisible because it broke nothing that already worked. A stored row + * carrying inline `config.authToken` bypasses the parse and still connects, and + * the host boot paths translate `OS_DATABASE_AUTH_TOKEN` / `TURSO_AUTH_TOKEN` + * into a `config` they construct themselves, which never meets the authoring + * schema. Only NEW authoring was dead. + * + * ## What each pin reads on `origin/main` (reverse verification) + * + * ⚠️ The vacuity trap this file is written around: any case that starts from an + * ALREADY-EXISTING turso datasource is green on `main` too, because stored config + * bypasses the parse. Every red pin below therefore starts at + * `createDatasource()` — the authoring door — and carries its credential the only + * way that door permits. + * + * RED on main (carries the defect this card fixes): + * - "a datasource created with a bound secret reaches the driver with an + * authToken" — on main the builder returned `{ url }` for the spec the connect + * path hands it, so the assertion on `authToken` failed. Measured on + * `origin/main`, not inferred. + * - "the bound secret is the ONLY credential a newly authored datasource can + * carry" — pins both halves at once: the door refuses the inline key AND the + * bound one arrives. On main the first half passed and the second failed, + * which is the dead end stated above. + * - "binds the secret to authToken alone, leaving the encryptionKey question + * open" — red on main via its `authToken` half. The `encryptionKey` half is + * forward-facing and is asserted BESIDE that one rather than alone, because + * alone it is green on main for the useless reason (main emitted neither key). + * + * GREEN on main (guards behaviour that must NOT change): + * - "#8078's refusal still fires at create and at update" — guards the spec half. + * This card restores the alternative route; it must not reopen the inline one. + * Passed on main and must keep passing. + * - "the refusal still names both mechanisms it diverts to" — the guidance is + * what makes the refusal actionable, and it now points at a route that + * actually works. Passed on main. + * - "postgres / mysql / mongodb still read `spec.secret` as the password" — + * guards the sibling arms this change takes its shape FROM. Untouched by the + * diff, unpinned before it. Passed on main and must keep passing. + * + * ## The one seam, stated rather than hidden + * + * The factory's real `turso` arm cannot run here: `@objectstack/driver-turso` is + * deliberately not resolvable from this package (that is what "optional" means, + * and the missing-package arm's own pin depends on it). So the red pins capture + * the spec the connect path actually hands `factory.create()` — real authoring + * door, real secret binder, real credential resolution — and run the real + * `buildTursoDriverConfig` on exactly that spec, which is the line the factory + * arm itself executes (`default-datasource-driver-factory.ts`, `kind === 'turso'`). + * Both halves are production code; only the `new TursoDriver(...)` call is absent. + */ + +import { describe, it, expect } from 'vitest'; +import { validateDriverConfig } from '@objectstack/spec/data'; +import { + DatasourceAdminService, + type DatasourceAdminServiceConfig, + type StoredDatasource, +} from '../datasource-admin-service.js'; +import { + DatasourceConnectionService, + type ConnectableDatasource, + type ConnectionEngineLike, +} from '../datasource-connection-service.js'; +import { buildTursoDriverConfig, resolveTursoUrl } from '../turso-driver-config.js'; +import { createDefaultDatasourceDriverFactory } from '../default-datasource-driver-factory.js'; +import type { + DatasourceConnectionSpec, + IDatasourceDriverFactory, +} from '../contracts/datasource-driver-factory.js'; + +const THE_BOUND_JWT = 'eyJhbGciOiJFZERTQSJ9.THE-BOUND-JWT'; +const TURSO_URL = 'libsql://my-db.turso.io'; + +/** + * An admin service over an in-memory record store and an in-memory secret store, + * with the two joined the way a real host joins them: `writeSecret` returns an + * opaque ref and keeps the cleartext where only a resolver can reach it. The + * cleartext must never appear on the record — asserted below rather than assumed. + */ +function makeAuthoringDoor() { + const records: StoredDatasource[] = []; + const secrets = new Map(); + let n = 0; + const cfg: DatasourceAdminServiceConfig = { + probe: async () => ({ ok: true }), + listDatasourceRecords: async () => records, + getDatasourceRecord: async (name) => records.find((r) => r.name === name), + putDatasourceRecord: async (rec) => { + const i = records.findIndex((r) => r.name === rec.name); + if (i >= 0) records[i] = rec; + else records.push(rec); + }, + deleteDatasourceRecord: async () => {}, + writeSecret: async (input) => { + const ref = `sys_secret:ds-${++n}`; + secrets.set(ref, input.value); + return ref; + }, + countBoundObjects: async () => 0, + }; + return { records, secrets, service: new DatasourceAdminService(cfg) }; +} + +/** The minimum engine the connect path needs to register a driver. */ +function stubEngine(): ConnectionEngineLike { + const drivers = new Map(); + return { + registerDriver: (driver: any) => { + drivers.set(driver.name, driver); + }, + registerDatasourceDef: () => {}, + getDriverByName: (name) => drivers.get(name), + syncObjectSchema: async () => {}, + markDatasourceUnavailable: () => {}, + clearDatasourceUnavailable: () => {}, + } as ConnectionEngineLike; +} + +/** + * A factory standing exactly where the real `turso` arm stands, recording the + * spec it is handed. This is the seam described in the header — everything + * upstream of it (door, binder, resolver) is production code. + */ +function capturingFactory() { + const seen: DatasourceConnectionSpec[] = []; + const factory: IDatasourceDriverFactory = { + supports: () => true, + create: async (spec) => { + seen.push(spec); + return { driver: { name: spec.name ?? 'default' } } as never; + }, + }; + return { seen, factory }; +} + +/** Author a turso datasource through the real door, then connect it. */ +async function authorThenConnect(secretValue: string) { + const { records, secrets, service } = makeAuthoringDoor(); + await service.createDatasource( + { name: 'warehouse', driver: 'turso', schemaMode: 'external', config: { url: TURSO_URL } }, + { value: secretValue }, + ); + + const record = records[0]!; + const { seen, factory } = capturingFactory(); + const connection = new DatasourceConnectionService({ + factory: () => factory, + engine: () => stubEngine(), + secrets: { resolve: async (ref) => secrets.get(ref) }, + }); + const result = await connection.connect(record as ConnectableDatasource); + return { record, seen, result }; +} + +describe('#8152 — the credential route a NEW turso datasource has left', () => { + it('RED ON MAIN — a datasource created with a bound secret reaches the driver with an authToken', async () => { + const { record, seen, result } = await authorThenConnect(THE_BOUND_JWT); + + // The door did its job: an opaque ref on the record, no cleartext anywhere in + // it. If this half ever fails the test below is measuring the wrong thing. + expect(result.status).toBe('connected'); + expect(record.external?.credentialsRef).toMatch(/^sys_secret:/); + expect(JSON.stringify(record)).not.toContain(THE_BOUND_JWT); + + // The connect path resolved the ref and handed the cleartext to the factory… + expect(seen).toHaveLength(1); + const spec = seen[0]!; + expect(spec.secret).toBe(THE_BOUND_JWT); + + // …and the builder the turso arm calls puts it in the slot libSQL reads. + // RED on main: this returned `{ url: 'libsql://my-db.turso.io' }`. + expect(buildTursoDriverConfig(spec, resolveTursoUrl(spec))).toMatchObject({ + url: TURSO_URL, + authToken: THE_BOUND_JWT, + }); + }); + + it('RED ON MAIN — the bound secret is the ONLY credential a newly authored datasource can carry', async () => { + const { service } = makeAuthoringDoor(); + + // Half one: the inline key is refused at the door (GREEN on main — #8078). + await expect( + service.createDatasource({ + name: 'inline', driver: 'turso', + config: { url: TURSO_URL, authToken: THE_BOUND_JWT }, + } as never), + ).rejects.toThrow(/is a credential and is not accepted inline/); + + // Half two: so the bound route must work, or there is none. RED on main. + const { seen } = await authorThenConnect(THE_BOUND_JWT); + const spec = seen[0]!; + expect(buildTursoDriverConfig(spec, resolveTursoUrl(spec)).authToken).toBe(THE_BOUND_JWT); + }); + + // ⛔ Step 1 is one slot: the primary credential. `encryptionKey` is a different + // secret (an AES-256 key for a local file, not a bearer token for a remote) and + // whether the binder needs a second slot for it is deliberately undecided — + // #8126's read-time redaction of it grants and removes no slot. This pin fails + // if a later change reads the one bound secret as though it were both. + // + // Both halves asserted together on purpose: `not.toHaveProperty` alone is + // green on main for the useless reason (main emitted neither key), which is + // exactly the vacuity this file is written to avoid. RED on main. + it('RED ON MAIN — binds the secret to authToken alone, leaving the encryptionKey question open', async () => { + const { seen } = await authorThenConnect(THE_BOUND_JWT); + const config = buildTursoDriverConfig(seen[0]!, resolveTursoUrl(seen[0]!)); + expect(config.authToken).toBe(THE_BOUND_JWT); + expect(config).not.toHaveProperty('encryptionKey'); + }); +}); + +describe('GREEN ON MAIN — #8078 is not reopened by the route this card restores', () => { + it('the inline refusal still fires at create and at update', async () => { + const { service } = makeAuthoringDoor(); + await expect( + service.createDatasource({ + name: 'inline', driver: 'turso', config: { url: TURSO_URL, authToken: 'jwt' }, + } as never), + ).rejects.toThrow(/is a credential and is not accepted inline/); + + // An existing row is not a licence to type the key back in. `updateDatasource` + // judges the MERGED config, so a patch that reintroduces it is refused too. + await service.createDatasource( + { name: 'warehouse', driver: 'turso', config: { url: TURSO_URL } }, + { value: THE_BOUND_JWT }, + ); + await expect( + service.updateDatasource('warehouse', { + config: { url: TURSO_URL, authToken: 'jwt' }, + } as never), + ).rejects.toThrow(/is a credential and is not accepted inline/); + }); + + it('the refusal still names both mechanisms it diverts to', () => { + // The guidance is what makes the refusal actionable — and as of this card the + // route it names is one that actually delivers the credential. A refusal that + // said only "not allowed" would leave the author with no next move. + const verdict = validateDriverConfig('turso', { url: TURSO_URL, authToken: 'jwt' }); + expect(verdict).toMatchObject({ known: true }); + const message = (verdict as { issues: Array<{ message: string }> }).issues[0]!.message; + expect(message).toContain('external.credentialsRef'); + expect(message).toContain('secret binder'); + }); + + it('a turso config with no credential at all is still accepted', () => { + // The url-only shape is what a bound datasource stores. It must stay legal: + // if this went red, binding would be unreachable for a different reason. + expect(validateDriverConfig('turso', { url: TURSO_URL })).toEqual({ known: true, issues: [] }); + }); +}); + +describe('GREEN ON MAIN — the sibling arms this change takes its shape from are unchanged', () => { + /** The knex config a constructed SqlDriver was built from. */ + function knexConfigOf(driver: any): any { + return driver?.config ?? driver?.knexConfig ?? driver?.options ?? {}; + } + + const factory = () => createDefaultDatasourceDriverFactory({ dev: false }); + + it('postgres still reads spec.secret as the connection password', async () => { + const handle: any = await factory().create({ + driver: 'postgres', + config: { host: 'db.internal', database: 'analytics', username: 'admin' }, + secret: 'hunter2', + }); + expect(knexConfigOf(handle.driver ?? handle).connection).toMatchObject({ password: 'hunter2' }); + try { await handle.disconnect?.(); } catch { /* pool never opened */ } + }); + + it('postgres still lets the bound secret win over an inline config.password', async () => { + const handle: any = await factory().create({ + driver: 'postgres', + config: { host: 'db.internal', database: 'analytics', username: 'admin', password: 'stale' }, + secret: 'hunter2', + }); + expect(knexConfigOf(handle.driver ?? handle).connection).toMatchObject({ password: 'hunter2' }); + try { await handle.disconnect?.(); } catch { /* pool never opened */ } + }); + + it('mysql still reads spec.secret as the connection password', async () => { + const handle: any = await factory().create({ + driver: 'mysql', + config: { host: 'db.internal', database: 'analytics', username: 'admin' }, + secret: 'hunter2', + }); + expect(knexConfigOf(handle.driver ?? handle).connection).toMatchObject({ password: 'hunter2' }); + try { await handle.disconnect?.(); } catch { /* pool never opened */ } + }); + + it('mongodb still reads spec.secret into the connection url', async () => { + const handle: any = await factory().create({ + driver: 'mongodb', + config: { host: 'db.internal', port: 27017, database: 'analytics', username: 'admin' }, + secret: 'hunter2', + }); + const driver: any = handle.driver ?? handle; + const url = driver?.config?.url ?? driver?.options?.url ?? driver?.url; + expect(url).toBe('mongodb://admin:hunter2@db.internal:27017/analytics'); + try { await handle.disconnect?.(); } catch { /* client never opened */ } + }); +}); diff --git a/packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts b/packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts index cdf44ab549..b709fd160e 100644 --- a/packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts +++ b/packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts @@ -14,6 +14,13 @@ // (`turso-driver-factory.convergence.test.ts`), and the correspondence with the // driver's own `TursoDriverConfig` from `packages/cli` — the only one of the // three packages that carries `@objectstack/driver-turso`. +// +// #8152 added the `secret` cases this file did not have — the absence is why the +// hole it fixes could exist. Their readings on `origin/main`: the three marked +// RED ON MAIN failed there (the builder emitted no `authToken` for a spec that +// carried only a bound secret); "still reads config.authToken when no secret is +// bound" passed on main and guards the host env-var route. The end-to-end +// authoring-door version lives in `turso-bound-secret-authoring.test.ts`. import { describe, it, expect } from 'vitest'; import { @@ -96,6 +103,90 @@ describe('buildTursoDriverConfig (#7314)', () => { .toEqual({ url: 'libsql://x', concurrency: 0, timeout: 0 }); }); + // #8152 — the case this file did not have. `authToken` read `config` alone, so + // the one credential route #8078 left open (bind it, reference it → resolved + // into `spec.secret`) was dropped on the floor. RED on `origin/main`: the + // builder returned `{ url }` and the assertion on `authToken` failed. + it('RED ON MAIN — reads the connection\'s bound secret into authToken', () => { + const spec: DatasourceConnectionSpec = { + name: 'warehouse', + driver: 'turso', + config: { url: 'libsql://my-db.turso.io' }, + secret: 'THE-BOUND-JWT', + external: { credentialsRef: 'sys_secret:abc' }, + }; + expect(buildTursoDriverConfig(spec, resolveTursoUrl(spec))) + .toEqual({ url: 'libsql://my-db.turso.io', authToken: 'THE-BOUND-JWT' }); + }); + + // Precedence is the SQL arms', not a new rule: `spec.secret ? … : cfg.password ? …`. + // The bound credential is the one an operator can rotate without an edit to + // `config`, so a stale inline value must never shadow it. + it('RED ON MAIN — prefers the bound secret over an inline config.authToken', () => { + const spec: DatasourceConnectionSpec = { + driver: 'turso', + config: { url: 'libsql://x', authToken: 'STALE-INLINE' }, + secret: 'THE-BOUND-JWT', + }; + expect(buildTursoDriverConfig(spec, resolveTursoUrl(spec)).authToken).toBe('THE-BOUND-JWT'); + }); + + // GREEN ON MAIN — guards the host env route. `OS_DATABASE_AUTH_TOKEN` / + // `TURSO_AUTH_TOKEN` are translated into `config: { url, authToken }` on a + // definition the CLI/standalone host constructs itself, which never meets the + // authoring schema that refuses the key. Reading `spec.secret` must not become + // reading it INSTEAD of `config`, or the `default` datasource's env credential + // disappears. Passed on main and must keep passing. + it('GREEN ON MAIN — still reads config.authToken when no secret is bound', () => { + const spec: DatasourceConnectionSpec = { + driver: 'turso', + config: { url: 'libsql://x', authToken: 'FROM-OS_DATABASE_AUTH_TOKEN' }, + }; + expect(buildTursoDriverConfig(spec, resolveTursoUrl(spec)).authToken) + .toBe('FROM-OS_DATABASE_AUTH_TOKEN'); + }); + + // An empty secret is an unset one on BOTH arms — the file's existing rule for + // string keys, extended to the new one rather than excepted from it. A blank + // `spec.secret` falls through to `config`; blank on both emits no key at all. + it('treats an empty bound secret as unset and falls through to config', () => { + const withConfig: DatasourceConnectionSpec = { + driver: 'turso', + config: { url: 'libsql://x', authToken: 'jwt' }, + secret: '', + }; + expect(buildTursoDriverConfig(withConfig, resolveTursoUrl(withConfig)).authToken).toBe('jwt'); + + const blankBoth: DatasourceConnectionSpec = { + driver: 'turso', + config: { url: 'libsql://x', authToken: '' }, + secret: '', + }; + expect(buildTursoDriverConfig(blankBoth, resolveTursoUrl(blankBoth))) + .toEqual({ url: 'libsql://x' }); + }); + + // ⛔ #8152 is step 1 and ONLY step 1: one slot, the primary credential. A bound + // secret must NOT be sprayed into `encryptionKey` — that is a different secret + // (an AES-256 key for a local file, not a bearer token for a remote), the + // binder has one slot, and deciding whether it needs a second is deliberately + // deferred until this lands. + // + // RED on main, for the shared reason (main emitted `{ url }`, so the key-set + // assertion failed). Its value is forward-facing: it fails if a later change + // resolves the encryptionKey question by GUESSING that one bound secret means + // both slots. + it('RED ON MAIN — binds the secret to authToken ONLY, never to encryptionKey', () => { + const spec: DatasourceConnectionSpec = { + driver: 'turso', + config: { url: 'libsql://x' }, + secret: 'THE-BOUND-JWT', + }; + const config = buildTursoDriverConfig(spec, resolveTursoUrl(spec)); + expect(config).not.toHaveProperty('encryptionKey'); + expect(Object.keys(config).sort()).toEqual(['authToken', 'url']); + }); + // ADR-0015 ownership reaches the driver from the datasource's OWN key, not // only from inside `config` — the #4410 fix. A loader reading `config` alone // constructs an `external` database as `managed`, with DDL ungated. diff --git a/packages/services/service-datasource/src/turso-driver-config.ts b/packages/services/service-datasource/src/turso-driver-config.ts index aff4295f9a..ecdd75d0d2 100644 --- a/packages/services/service-datasource/src/turso-driver-config.ts +++ b/packages/services/service-datasource/src/turso-driver-config.ts @@ -84,7 +84,11 @@ export interface TursoDriverConfigInput { /** What every reader in {@link TURSO_CONFIG_READERS} is handed. */ interface TursoConfigSource { - /** The whole spec — `schemaMode` is resolved from three places, not just `config`. */ + /** + * The whole spec — two readers need more than `config`: `schemaMode` is + * resolved from three places, and `authToken` reads the connection's bound + * `spec.secret` ahead of `config` (#8152). + */ spec: DatasourceConnectionSpec; /** `spec.config`, narrowed to a bag so each reader can type-test its own key. */ config: Record; @@ -101,10 +105,14 @@ interface TursoConfigSource { * an absent option from an explicit `undefined` in places, and the open-core arm * this was lifted from had always spread-omitted. * - * The type-tests are the open-core arm's, unchanged — including the truthiness - * check on the string keys (an empty `authToken` / `syncUrl` / `encryptionKey` is - * an unset one, never a credential of length zero) and its absence on the number - * keys (`concurrency: 0` and `timeout: 0` are meaningful values the driver reads). + * The type-tests are the open-core arm's — including the truthiness check on the + * string keys (an empty `authToken` / `syncUrl` / `encryptionKey` is an unset one, + * never a credential of length zero) and its absence on the number keys + * (`concurrency: 0` and `timeout: 0` are meaningful values the driver reads). + * + * A reader is not obliged to read only `config`: `schemaMode` and `authToken` + * both consult the spec itself. `authToken`'s reason is a credential route, and + * is worth reading before changing it (#8152). */ const TURSO_CONFIG_READERS: { readonly [K in keyof Required]: ( @@ -112,8 +120,44 @@ const TURSO_CONFIG_READERS: { ) => TursoDriverConfigInput[K] | undefined; } = { url: ({ url }) => url, - authToken: ({ config }) => - typeof config.authToken === 'string' && config.authToken ? config.authToken : undefined, + /** + * The BOUND credential first, the config key second (#8152). + * + * This reader used to consult `config.authToken` alone, and that was a hole + * rather than a preference: #7990/#8078 made `authToken` a refused inline key + * (`z.never()`) at every authoring door, so the only route an author has left + * is to bind the credential and reference it — and the resolved secret arrives + * as `spec.secret`, which nothing on the turso path read. A turso datasource + * created after #8078 therefore had NO working credential route: the inline + * one is refused at the door and the bound one was dropped here. Existing + * stored rows kept working (stored config bypasses the parse), which is what + * kept it invisible — only new authoring was dead. + * + * The precedence and the shape are exact parity with the SQL arms in + * `default-datasource-driver-factory.ts` + * (`spec.secret ? { password: spec.secret } : cfg.password ? { password: cfg.password } : {}`): + * a datasource's bound secret WINS over anything in `config`, and `config` is + * the fallback. `spec` is already on {@link TursoConfigSource} for `schemaMode`, + * so this needs no new plumbing — the credential was reaching this function + * all along. + * + * `config.authToken` stays readable, and deliberately: it is not only legacy + * stored rows. The host boot paths translate `OS_DATABASE_AUTH_TOKEN` / + * `TURSO_AUTH_TOKEN` into `config: { url, authToken }` on a definition they + * construct themselves (`packages/cli/src/utils/storage-driver.ts`), which + * never meets the authoring schema. Dropping this arm would break the `default` + * datasource's env credential — a live route, refused only for AUTHORS. + * + * The truthiness test carries over unchanged, now on both arms: an empty + * `spec.secret` is an unset one, and falls through to `config` rather than + * emitting a credential of length zero. + */ + authToken: ({ spec, config }) => + spec.secret + ? spec.secret + : typeof config.authToken === 'string' && config.authToken + ? config.authToken + : undefined, encryptionKey: ({ config }) => typeof config.encryptionKey === 'string' && config.encryptionKey ? config.encryptionKey : undefined, concurrency: ({ config }) => (typeof config.concurrency === 'number' ? config.concurrency : undefined),