diff --git a/.changeset/25050-retire-turso-encryption-key.md b/.changeset/25050-retire-turso-encryption-key.md new file mode 100644 index 00000000000..6ee29fb2065 --- /dev/null +++ b/.changeset/25050-retire-turso-encryption-key.md @@ -0,0 +1,46 @@ +--- +'@objectstack/spec': minor +'@objectstack/driver-turso': minor +'@objectstack/service-datasource': minor +--- + +feat(spec,driver-turso)!: turso `config.encryptionKey` is removed — it never encrypted anything, in any mode, and is now refused everywhere it is written instead of accepted on a remote datasource + +Clause-②: no (narrowing) + +Metadata-protocol: narrowed + + + +`encryptionKey` was declared as an AES-256 key for the database file. Nothing ever applied it. A local or embedded-replica file is read and written through better-sqlite3, which has no encryption codec, so #25020 already refused the key there. A remote database was never handed the key either: its storage encryption is configured on the server. That left a key that parsed on a remote datasource and did nothing. It was also the one credential-shaped key a datasource could still write in plain text (`STILL_WRITABLE_CREDENTIAL_KEYS`). Under ADR-0049 (enforce-or-remove) it is removed. + +**BREAKING** accept-set narrowing on a published schema, a published constructor and an exported type. It ships as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). + +- **`@objectstack/spec`**: `TursoConfigSchema.encryptionKey` is a `retiredKey()` tombstone. Authoring it is a `tsc` error and a parse error at `config.encryptionKey`, in every mode and with any value, the empty string included. The error carries the removal prescription. It is the only issue reported on that config, whatever else is wrong. The misspellings `encryption_key` and `encryptionkey` get the same prescription. The #25020 local and replica refusal arms are gone, since the tombstone subsumes them. +- **`@objectstack/driver-turso`**: the published mirror `TursoConfigSchema` refuses the key the same way, as `z.never()`. `TursoDriverConfig.encryptionKey` is typed `never`. `new TursoDriver()` and `createTursoDriver()` refuse a defined `encryptionKey` with `VALIDATION_ERROR` / 400 in every mode, before anything else is checked, in the schema's words. +- **`@objectstack/service-datasource`**: the exported `TursoDriverConfigInput.encryptionKey` is typed `never`. The shared loader still forwards a declared value, so a legacy stored row reaches the constructor's refusal instead of being dropped in silence. + +### Migration: FROM → TO + +| You wrote | Write instead | +| --- | --- | +| `url: 'libsql://my-db.turso.io', encryptionKey: '…'` (also `https://`, `ws(s)://`) | delete `encryptionKey`. Storage encryption for a remote database is configured on the Turso server | +| `encryptionKey: ''` (or any unset value) | delete `encryptionKey` | +| `url: 'file:./data/app.db', encryptionKey: '…'` (also `:memory:`, or a replica beside `syncUrl`) | delete `encryptionKey` (already refused since #25020). Put the file on an encrypted disk or volume if it must be encrypted at rest | + +The fix in one line: delete `encryptionKey` from every turso datasource. + +### The ADR-0087 disposition + +- **D2 `turso-config-encryption-key-removed`** deletes the key from a turso datasource's `config` when its value is unset (any non-string, or `''`) or when it sits beside a remote url (`libsql://`, `https://`, `http://`, `ws://`, `wss://`, in any case). It emits one notice per key. Nothing is lost: the driver never read the value. +- A non-empty key beside a local url is **not** deleted. Removing it would let a datasource whose operator believed the file was encrypted boot in silence. It keeps the #25020 posture instead: the row fails at driver construction with the prescription, and the author deletes the key by hand. +- The conversion is retired from the load path. A live author is refused at parse and taught. Stored rows (the datasource loaders), built artifacts inside their declared-floor window and `os migrate meta --from 17` replay it. +- `RETIRED_KEYS_BY_MAJOR[18]` gains `data/TursoConfig:encryptionKey`. The D3 entry is `turso-config-encryption-key-retired`. + +### Stored rows stay redacted + +A tombstone is excluded from the schema-derived credential list, by its `[REMOVED]` prefix. Measured: after the retirement, no existing path would have withheld a stored `encryptionKey` on read. The derived turso list (`authToken`) is non-empty, so the canonical fallback never applies. `STILL_WRITABLE_CREDENTIAL_KEYS` is therefore renamed `RETIRED_CREDENTIAL_KEYS` and keeps `turso: ['encryptionKey']`. A legacy row's value is still withheld and still named in `redactedConfigKeys`, on the admin door and the metadata door alike. + +### Blast radius + +Measured on this tree: no example, template, CLI scaffold, published skill, host default or environment variable authors the key. A tree-scoped absence pin now holds that. Whether any out-of-repo deployment declares it is not measured, and is not claimed to be zero. diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx index fca23ec72a1..cf4b8b405d1 100644 --- a/content/docs/data-modeling/drivers.mdx +++ b/content/docs/data-modeling/drivers.mdx @@ -195,15 +195,14 @@ ObjectStack — is what makes them secrets: | `mongodb` | `options.key` | TLS private key material (PEM) | | `mongodb` | `options.passphrase` | TLS key passphrase | | `mongodb` | `options.authMechanismProperties.AWS_SESSION_TOKEN` | AWS STS session token (`MONGODB-AWS` auth) | -| `turso` \| `libsql` | `encryptionKey` | A key the driver never applies (see below) | Unlike `password` / `authToken` (typed `never` in their schemas — the parse -refuses them outright), these six keys are **writable**: the parse accepts +refuses them outright), these five keys are **writable**: the parse accepts them, and they are stored **at rest in `sys_metadata` as plain text**, right alongside the rest of the datasource row. The protection that exists today is on the READ side only — every one of them is stripped before a datasource record is ever served back over the admin API or shown in the Setup UI -(`PASSTHROUGH_SECRET_PATHS` / `STILL_WRITABLE_CREDENTIAL_KEYS` in +(`PASSTHROUGH_SECRET_PATHS` in `datasource-credential-redaction.ts`, shipped in [#9040](https://github.com/objectstack-ai/objectstack/issues/9040)). That stops the value round-tripping through a read; it is not encryption at rest, and an operator @@ -231,19 +230,17 @@ that needs an authenticated proxy or a passphrase-protected key converts that group into named binder-slot support — one mechanism covering those four keys — rather than the current per-key accept-and-document posture. -Turso's `encryptionKey` is no longer part of that trade-off either. It was -declared as an AES-256 key for the local database file, but the driver reads -and writes that file through better-sqlite3, which has no encryption codec, so -the file was written in plaintext whatever the key said. A local or -embedded-replica turso datasource that declares it is now refused at write -time and when the driver is built: drop the key, and put the file on an -encrypted disk or volume if it must be encrypted at rest. A remote turso -datasource (a `libsql://`, `https://` or `ws(s)://` url) still parses with the -key, which the driver does not forward (a remote database's storage encryption -is the server's), while a `file:` url or `:memory:` under a forced -`mode: 'remote'` is refused like a local one, because the libSQL client the -driver builds for it is handed no key; a stored value is redacted on read like -the rest of this table. +Turso's `encryptionKey` is not part of that trade-off: it was **removed** in +`@objectstack/spec` 18 ([#25050](https://github.com/objectstack-ai/objectstack/issues/25050), ADR-0049). +It was declared as an AES-256 key for the local database file, but the driver +reads and writes that file through better-sqlite3, which has no encryption +codec, and it never handed the key to a remote database, whose storage +encryption the server configures. So it encrypted nothing, in any mode. The +parse and the driver constructor now refuse it in every mode: delete the key, +and put a local file on an encrypted disk or volume if it must be encrypted at +rest. `os migrate meta --from 17` deletes it for you where it was unset or +beside a remote url; a stored legacy row that still carries it is redacted on +read like the keys above. ## Startup: a driver that cannot connect aborts the boot diff --git a/content/docs/references/data/driver-turso.mdx b/content/docs/references/data/driver-turso.mdx index 57f9ef558e8..b3da663c79c 100644 --- a/content/docs/references/data/driver-turso.mdx +++ b/content/docs/references/data/driver-turso.mdx @@ -68,7 +68,7 @@ Turso / libSQL Connection Configuration | :--- | :--- | :--- | :--- | | **url** | `string` | ✅ | libSQL endpoint or local file: a remote libsql/https Turso URL, a local file written as a file: URL (never a bare path), or :memory: | | **authToken** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` | -| **encryptionKey** | `string` | optional | Not honoured in any mode: refused for a local or replica datasource, whose SQLite engine cannot encrypt the file, and for a file: url or :memory: under a forced remote mode, whose libSQL client is handed no key; a remote database's storage encryption is the server's, not set by this key | +| **encryptionKey** | `never` | optional | [REMOVED] `turso config.encryptionKey` was removed in @objectstack/spec 18 and @objectstack/driver-turso 18 (ADR-0049) — it never encrypted anything, in any mode: a local or embedded-replica file is read and written through a SQLite engine that has no encryption codec, and the driver never handed the key to a remote database, whose storage encryption the server configures. Delete the key. A local file that must be encrypted at rest belongs on an encrypted disk or volume. Run `os migrate meta --from 17` to list the mechanical edits for the unset and remote-url cases; `--write` applies the ones it can prove, and you delete the key from a local or replica datasource by hand. | | **concurrency** | `integer` | optional | Maximum concurrent requests to the remote database | | **syncUrl** | `string` | optional | Remote sync URL for embedded-replica mode: a libsql or https Turso endpoint | | **sync** | `{ intervalSeconds?: integer; onConnect?: boolean }` | optional | Embedded-replica sync configuration (requires `syncUrl`) | diff --git a/docs/design/driver-turso.md b/docs/design/driver-turso.md index c3aa5725191..229d7fdd42c 100644 --- a/docs/design/driver-turso.md +++ b/docs/design/driver-turso.md @@ -516,14 +516,13 @@ package-published Spec / Studio mirror in `packages/drivers/driver-turso/src/spe Neither declares a key the driver does not read — the direction ADR-0049 (enforce-or-remove) governs. The converse does not hold, and the mirror is the shorter list: it does not declare `mode`, which the driver does read (`TursoDriverConfig.mode`). The mirror keeps its retired keys -(`timeout` → `timeoutMs`; `localPath` and `wasm`, removed) as `z.never()` tombstones whose refusal +(`timeout` → `timeoutMs`; `localPath`, `wasm` and — since #25050, because it encrypted nothing in any mode — `encryptionKey`, removed) as `z.never()` tombstones whose refusal carries the prescription. The live keys: | Property | Type | Default | Description | |:---|:---|:---:|:---| | `url` | `string` | (required) | Database URL (`libsql://`, `https://`, `file:`, `:memory:`) — in replica mode, also the local file | | `authToken` | `string?` | — | JWT auth token for remote databases | -| `encryptionKey` | `string?` | — | Encrypts nothing, in any mode: refused for a local or replica datasource (`VALIDATION_ERROR` / 400 at construction, a `custom` issue at authoring), because the local engine (better-sqlite3) has no encryption codec, and for a `file:` url or `:memory:` under a forced `mode: 'remote'`, whose libSQL client is handed no key; a remote database's storage encryption is the server's, not set by this key | | `concurrency` | `number` | `20` | Maximum concurrent requests | | `syncUrl` | `string?` | — | Remote sync URL for embedded replica mode | | `sync.intervalSeconds` | `number` | `60` | Periodic sync interval (0 = manual only) | diff --git a/docs/qa/platform-checklist/areas/integration-system.json b/docs/qa/platform-checklist/areas/integration-system.json index 27b6117e715..aa4f1b8cecd 100644 --- a/docs/qa/platform-checklist/areas/integration-system.json +++ b/docs/qa/platform-checklist/areas/integration-system.json @@ -1716,10 +1716,10 @@ ], "knownGaps": [ "The legacy-alias clause tests a row shape the CURRENT parse refuses to create — a stored row from before the aliases moved to `guidance`. It is only reachable by writing the row directly (⚠️ NOT through the runtime wizard: POST /api/v1/datasources refuses an alias-spelled config key 400 DATASOURCE_ADMIN_ERROR — the aliases moved to `guidance`, which refuses them, #8078 — measured on a stock showcase boot with `passwd` and by QA run #21056. The run planted its rows with a direct sys_metadata write while the server was STOPPED, then booted and read through both doors). If the run cannot write such a row, that clause is blocked(fixture) and MUST be recorded — a redaction that has never been tested against the shape it exists for is an untested redaction.", - "`encryptionKey` (turso) is deliberately STILL WRITABLE on a REMOTE turso datasource while being redacted on read (#8081 scope item 4). Scoring it as a write refusal there is a false fail; the correct expectation is write-accepted + read-redacted. On a local or embedded-replica turso datasource (a file: url, with or without syncUrl) the write IS refused since #25020, on config.encryptionKey: the driver cannot encrypt that file, so that refusal is the correct expectation, not a false fail.", + "`encryptionKey` (turso) is RETIRED since #25050 (it encrypted nothing in any mode): the parse refuses it on EVERY turso datasource, remote or local, on config.encryptionKey with the removal prescription, so a write refusal is the correct expectation everywhere. A stored row from before the retirement may still carry it; such a row is reachable only by a direct sys_metadata write, and its value must still be redacted on read through both doors (packages/spec/src/data/datasource-credential-redaction.ts#RETIRED_CREDENTIAL_KEYS). Plant it beside a file: url: the load-path conversion (turso-config-encryption-key-removed) deletes it beside a remote url on the datasource loaders, and leaves a local-url value for its author.", "acceptance[1] (the credential-bound datasource actually connects) and acceptance[6] (judged by a connect probe after the echoed-config round-trip) need a reachable, credential-protected database of a SHIPPED driver (postgres, mysql or mongodb — the driver id since 18; an authored `mongo` is refused by name, packages/spec/src/data/datasource.zod.ts). Stock showcase provides none, but the QA container has a PostgreSQL 16 install (/usr/lib/postgresql/16/bin, with a `postgres` system user), and QA run #24575 proved a recipe on it that scored both clauses pass (#24615 reused it): as `postgres`, `initdb -A scram-sha-256` into a scratch data directory, then `pg_ctl … -o '-p PORT -k SOCKETDIR -c listen_addresses=127.0.0.1' start`; create a login role WITH a password and a database it owns; bind it through POST /api/v1/datasources with the password in the top-level `secret`, never in `config`, so the door binds external.credentialsRef. Every parent directory of the data and socket directories must be traversable by `postgres`. Runs before #24575 recorded both clauses blocked(environment) (#21784, #21845). CI has postgres and mysql only inside the Temporal Conformance (live PG + MySQL) job (.github/workflows/ci.yml services postgres:16 and mysql:8.0), which runs no checklist item, and has no mongodb anywhere. ⛔ Never score either clause from a successful publish alone, which proves the paved path is accepted, not that it connects. A run that cannot start the database still reads the stored-credential half of acceptance[6] (after the PATCH, the admin door still reports hasSecret and the bound secret handle is unchanged); record that as a partial reading, separate from the connect half", "the plugin-driver boundary is RULED (#21921, docs note in content/docs/data-modeling/drivers.mdx): for a driver the platform ships no contract for, the platform neither validates config nor guesses which of its keys are credentials. Keeping credentials out of a plugin driver's config, through external.credentialsRef, is the plugin author's responsibility. Only the platform's FIXED credential spellings are redacted there by name (acceptance[5]). A credential under any other key of a plugin driver's config being stored and served to administrators as written is that boundary, never a finding", - "no datasource read door serves the mask literal (packages/spec/src/data/secret-mask.ts#SECRET_MASK is read by the encrypted-field read path and the settings REST boundary, not here): the admin door OMITS each withheld credential key and names it in redactedConfigKeys beside hasSecret, and the metadata door serves the external.credentialsRef handle. So the round-trip acceptance[6] drives carries no mask, and what it exercises is the carry-forward of the withheld keys on PATCH (packages/services/service-datasource/src/datasource-config-redaction.ts#restoreRedactedConfig). QA run #24575 also measured a hand-written literal mask: PATCH /api/v1/datasources/NAME with `secret` set to it is accepted 200 and rewraps the stored secret to the mask (the reconnect then fails), and `config.encryptionKey` set to it overwrites the stored key. No read door serves that string, so it is not the echoed case acceptance[6] and negative[5] score; it is held for the maintainer on #24579 §7, where no contract declares it. ⛔ Score it under neither" + "no datasource read door serves the mask literal (packages/spec/src/data/secret-mask.ts#SECRET_MASK is read by the encrypted-field read path and the settings REST boundary, not here): the admin door OMITS each withheld credential key and names it in redactedConfigKeys beside hasSecret, and the metadata door serves the external.credentialsRef handle. So the round-trip acceptance[6] drives carries no mask, and what it exercises is the carry-forward of the withheld keys on PATCH (packages/services/service-datasource/src/datasource-config-redaction.ts#restoreRedactedConfig). QA run #24575 also measured a hand-written literal mask: PATCH /api/v1/datasources/NAME with `secret` set to it is accepted 200 and rewraps the stored secret to the mask (the reconnect then fails). No read door serves that string, so it is not the echoed case acceptance[6] and negative[5] score; it is held for the maintainer on #24579 §7, where no contract declares it. ⛔ Score it under neither" ] }, "steps": [ @@ -1730,7 +1730,7 @@ "read the SAME datasource back through the metadata door (GET /api/v1/meta/datasource/) — a different code path with its own redaction hook", "plant a stored row carrying a legacy alias spelling (passwd / pwd / token / jwt / auth_token / authtoken) and read it back through both doors", "author a datasource for a driver the platform ships no contract for, carrying a canonically-spelled credential key (password or authToken) AND one non-canonical credential-looking key (for example apiSecret), and read it back. The second key observes the ruled boundary; it is not a redaction target", - "for turso specifically, write `encryptionKey` on a remote (libsql:// or https://) turso datasource, then read it back through both doors", + "for turso specifically, confirm `encryptionKey` is refused at publish on a remote (libsql:// or https://) AND a local (file:) turso datasource, then plant a stored row carrying it beside a file: url and read it back through both doors", "grep the full response bodies of every read above for the planted cleartext values, and of the third read door, GET /api/v1/data/sys_metadata?type=datasource (admin only), which serves the stored row and also redacts (QA run #24575)" ], "acceptance": [ @@ -1797,7 +1797,7 @@ "ADR-0100 (credential read mask; §B3 echoed-mask write guard)", "ADR-0015 / ADR-0062 (credentials route through datasource config)", "packages/spec/src/data/secret-mask.ts#SECRET_MASK (the ADR-0100 mask literal; its two readers are the encrypted-field read path and the settings REST boundary, and neither datasource read door imports it: both withhold the key instead, knownGaps[4])", - "packages/spec/src/data/datasource-credential-redaction.ts#STILL_WRITABLE_CREDENTIAL_KEYS (the ONE definition of 'what is a credential key' — schema-derived z.never() refusals, FORMER_CREDENTIAL_ALIASES, STILL_WRITABLE_CREDENTIAL_KEYS)", + "packages/spec/src/data/datasource-credential-redaction.ts#RETIRED_CREDENTIAL_KEYS (the ONE definition of 'what is a credential key' — schema-derived z.never() refusals, FORMER_CREDENTIAL_ALIASES, RETIRED_CREDENTIAL_KEYS)", "packages/spec/src/kernel/metadata-type-redaction.ts (the per-type redaction hook — the metadata read door)", "packages/services/service-datasource/src/datasource-config-redaction.ts + datasource-credential-migration.ts (the datasource-admin read door)", "packages/spec/src/migrations/entries/semantic/17.datasource-config-inline-credential-refused.ts · 17.datasource-config-placeholder-refused.ts · 17.datasource-config-url-userinfo-refused.ts · 17.connector-inline-authentication-publish-refused.ts", diff --git a/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts b/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts index d27f6974378..05f23bb99d6 100644 --- a/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts +++ b/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts @@ -429,7 +429,7 @@ await k('sys_audit_log').insert({ id: 'aud_21552', object_name: 'sys_metadata', record_id: 'm_21552', action: 'create', new_value: JSON.stringify({ id: 'm_21552', name: 'ds', type: 'datasource', scope: 'platform', - metadata: JSON.stringify({ name: 'ds', driver: 'turso', config: { url: 'libsql://db.turso.io', encryptionKey: 'cleartext-21552' } }), + metadata: JSON.stringify({ name: 'ds', driver: 'turso', config: { url: 'libsql://db.turso.io', authToken: 'cleartext-21552' } }), }), }); const shard = (await k.raw("select name from sqlite_master where type = 'table' and name glob 'sys_activity__r*'"))[0]; @@ -440,7 +440,7 @@ await k(shard.name).insert({ old: null, new: { id: 'm_21552', name: 'ds', type: 'datasource', scope: 'platform', - metadata: JSON.stringify({ name: 'ds', driver: 'turso', config: { url: 'libsql://db.turso.io', encryptionKey: 'cleartext-21552-activity' } }), + metadata: JSON.stringify({ name: 'ds', driver: 'turso', config: { url: 'libsql://db.turso.io', authToken: 'cleartext-21552-activity' } }), }, }), }); diff --git a/packages/cli/src/commands/migrate/preview-read-only.integration.test.ts b/packages/cli/src/commands/migrate/preview-read-only.integration.test.ts index 06cc13b813d..0f8e6fdc944 100644 --- a/packages/cli/src/commands/migrate/preview-read-only.integration.test.ts +++ b/packages/cli/src/commands/migrate/preview-read-only.integration.test.ts @@ -106,7 +106,7 @@ const AUDIT_COPY = JSON.stringify({ name: 'ds', type: 'datasource', scope: 'platform', - metadata: JSON.stringify({ name: 'ds', driver: 'turso', config: { url: 'libsql://db.turso.io', encryptionKey: CRED } }), + metadata: JSON.stringify({ name: 'ds', driver: 'turso', config: { url: 'libsql://db.turso.io', authToken: CRED } }), }); /** Env that would point the command's boot somewhere other than the fixture. */ diff --git a/packages/cli/src/utils/storage-driver.test.ts b/packages/cli/src/utils/storage-driver.test.ts index d08f4db7b65..b945aa354f6 100644 --- a/packages/cli/src/utils/storage-driver.test.ts +++ b/packages/cli/src/utils/storage-driver.test.ts @@ -645,7 +645,6 @@ describe('#7314 — the shared libSQL config builder against the real TursoDrive config: { url: 'libsql://my-db.turso.io', authToken: 'jwt-token', - encryptionKey: 'aes-256-key', concurrency: 7, syncUrl: 'libsql://replica.turso.io', sync: { intervalSeconds: 30, onConnect: false }, @@ -656,10 +655,14 @@ describe('#7314 — the shared libSQL config builder against the real TursoDrive const built = buildTursoDriverConfig(spec, resolveTursoUrl(spec)); const pinned: TursoDriverConfig = built; - expect(pinned.encryptionKey).toBe('aes-256-key'); + // `encryptionKey` is retired (#25050): typed `never` on BOTH sides, so the + // compile-time pin above holds it as read-and-forwarded (a legacy stored + // row reaches the constructor's refusal), and no datasource can declare it. + expect(pinned).not.toHaveProperty('encryptionKey'); expect(pinned.syncUrl).toBe('libsql://replica.turso.io'); expect(pinned.sync).toEqual({ intervalSeconds: 30, onConnect: false }); - expect(Object.keys(built).sort()).toEqual([...TURSO_DRIVER_CONFIG_KEYS].sort()); + expect(Object.keys(built).sort()) + .toEqual(TURSO_DRIVER_CONFIG_KEYS.filter((k) => k !== 'encryptionKey').sort()); }); }); diff --git a/packages/drivers/driver-turso/README.md b/packages/drivers/driver-turso/README.md index b45ab7e111f..87aa7b5d705 100644 --- a/packages/drivers/driver-turso/README.md +++ b/packages/drivers/driver-turso/README.md @@ -250,16 +250,13 @@ that nothing would honour, each with the message `@objectstack/spec`'s would be ignored. Drop `mode` for an embedded replica, or drop `syncUrl` (and `sync`) for a plain local database. -It refuses (`VALIDATION_ERROR` / 400) a non-empty `encryptionKey` on a local -database or an embedded replica too, in the same message the spec contract -gives at authoring. The driver reads and writes that file through -better-sqlite3, which has no encryption codec, so the key would encrypt nothing -and the file would be written in plaintext that any SQLite reader opens without -it. Drop `encryptionKey`, and put the file on an encrypted disk or volume if it -must be encrypted at rest, or use a remote Turso database, whose storage -encryption is the server's to configure. A `file:` url or `:memory:` under a -forced `mode: 'remote'` is refused the same way: the libSQL client the driver -builds for that url is handed no key. +It refuses (`VALIDATION_ERROR` / 400) any `encryptionKey`, in every mode, in +the same message the spec contract gives at authoring: the key was removed in +18 because it never encrypted anything. A local or replica file is read and +written through better-sqlite3, which has no encryption codec, and the driver +never handed the key to a remote database. Delete the key; put a local file on +an encrypted disk or volume if it must be encrypted at rest, or use a remote +Turso database, whose storage encryption the server configures. You can also force a specific mode: @@ -337,13 +334,6 @@ interface TursoDriverConfig { /** JWT auth token for the remote Turso database */ authToken?: string; - /** - * Encrypts nothing, in any mode: refused in local and replica modes - * (VALIDATION_ERROR / 400), because the local engine cannot encrypt the file, - * and beside a file: url or :memory: under a forced mode: 'remote'. - */ - encryptionKey?: string; - /** * Maximum concurrent requests to the remote database. * Effective in replica and remote modes. diff --git a/packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts b/packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts index 7b4ef196a37..bf1a3a7c3a3 100644 --- a/packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts +++ b/packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts @@ -33,18 +33,15 @@ * everywhere, as a replica that never synced. [#20586] And for a forced * `mode: 'local'` beside a `syncUrl`, refused on `mode` too — the fourth * copy. That row used to be accepted everywhere as well, as a "local" - * database the driver synced with the remote anyway. [#25020] And for a - * non-empty `encryptionKey` in a local or replica mode, refused on - * `encryptionKey` — the fifth copy, one text per mode. Those rows used to - * be accepted everywhere, and the driver wrote the file in plaintext: its - * local engine (better-sqlite3) has no encryption codec. And for the same - * key beside a `file:` url or `:memory:` under a forced `mode: 'remote'`, - * whose libSQL client is handed no key — the sixth copy, one text for a - * file and one for an in-memory database. The mirror declares no `mode`, so - * it judges those configs as the local database their url selects (it - * refuses them on `encryptionKey` in the local arm's words); its own copy - * of the remote arm is unreachable through it and kept byte-identical, as - * its other forced-mode branches are. + * database the driver synced with the remote anyway. + * - [#25050] `encryptionKey` is REMOVED: a tombstone in both schemas and a + * refusal in the constructor, in every mode and for every value, ahead of + * every other refusal, in ONE text at all three places. Its own table + * (`RETIRED_KEY_ROWS`, below) holds the three to that text byte for byte. + * It is kept apart from `ROWS` because a tombstone's issue is not a + * `custom` one and the mirror judges it too, forced `mode` or not (the key + * is refused before any mode is read). #25020 refused the key beside a + * local url in one text per mode; those rows moved there. * * ⚠️ The mirror declares no `mode`, so zod strips an authored one before its * refinement runs: rows that FORCE a mode are judged by the constructor and the @@ -77,7 +74,7 @@ afterAll(() => rmSync(DIR, { recursive: true, force: true })); const FILE = `file:${DIR}/app.db`; const REMOTE = 'libsql://db.example.turso.io'; /** A test value only — never a real key. */ -const KEY = 'test-key-25020'; +const KEY = 'test-key-25050'; type Authored = { url: string; @@ -85,7 +82,6 @@ type Authored = { sync?: { intervalSeconds?: number; onConnect?: boolean }; mode?: 'local' | 'replica' | 'remote'; timeoutMs?: number; - encryptionKey?: string; }; interface Row { @@ -94,7 +90,7 @@ interface Row { /** The constructor's verdict on this config. */ ctor: 'accept' | 'refuse'; /** The key both schemas refuse on, or `undefined` when they accept. */ - refusedOn?: 'url' | 'syncUrl' | 'timeoutMs' | 'sync' | 'mode' | 'encryptionKey'; + refusedOn?: 'url' | 'syncUrl' | 'timeoutMs' | 'sync' | 'mode'; /** How many issues the spec contract raises on a refused row; 1 unless stated. */ issues?: number; /** The constructor accepts it and ignores a key: refused at authoring only. None today (#20200). */ @@ -125,15 +121,7 @@ const ROWS: Row[] = [ { name: "libsql:// under a forced mode: 'remote'", config: { url: REMOTE, mode: 'remote' }, ctor: 'accept' }, { name: "file: under a forced mode: 'remote'", config: { url: FILE, mode: 'remote' }, ctor: 'accept' }, { name: "a bare path under a forced mode: 'remote' (the client refuses it at connect)", config: { url: './data/app.db', mode: 'remote' }, ctor: 'accept' }, - // [#25020] An empty key is unset, as the loaders read it. - { name: 'an empty encryptionKey (unset) beside a file: url', config: { url: FILE, encryptionKey: '' }, ctor: 'accept' }, - { name: 'an empty encryptionKey (unset) on a replica', config: { url: FILE, syncUrl: REMOTE, sync: { onConnect: false }, encryptionKey: '' }, ctor: 'accept' }, - // [#25020, patch round 1] The forced-remote local-url arm's controls: no key, an empty key, and a - // remote url with a key — a remote server's storage, which this card does not judge. { name: ":memory: under a forced mode: 'remote'", config: { url: ':memory:', mode: 'remote' }, ctor: 'accept' }, - { name: "an empty encryptionKey (unset) beside file: under a forced mode: 'remote'", config: { url: FILE, mode: 'remote', encryptionKey: '' }, ctor: 'accept' }, - { name: 'encryptionKey beside a libsql:// url (remote: not judged by this card)', config: { url: REMOTE, encryptionKey: KEY }, ctor: 'accept' }, - { name: "encryptionKey beside libsql:// under a forced mode: 'remote' (not judged by this card)", config: { url: REMOTE, mode: 'remote', encryptionKey: KEY }, ctor: 'accept' }, // ── a remote url in a local or replica mode ───────────────────────────── { name: 'libsql:// beside syncUrl', config: { url: REMOTE, syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' }, @@ -204,75 +192,74 @@ const ROWS: Row[] = [ { name: "file: + a wss:// syncUrl under a forced mode: 'local'", config: { url: FILE, mode: 'local', syncUrl: 'wss://db.example.turso.io' }, ctor: 'refuse', refusedOn: 'mode' }, { name: ":memory: + syncUrl under a forced mode: 'local'", config: { url: ':memory:', mode: 'local', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'mode' }, { name: "file::memory: + syncUrl under a forced mode: 'local'", config: { url: 'file::memory:', mode: 'local', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'mode' }, - - // ── an encryptionKey the local engine cannot apply: refused on `encryptionKey` (#25020) ── - // Every row here was accepted everywhere until #25020, and the driver wrote the file in plaintext. - { name: 'encryptionKey beside a file: url', config: { url: FILE, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, - { name: 'encryptionKey beside an uppercase FILE: url', config: { url: `FILE:${DIR}/upper-key.db`, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, - { name: 'encryptionKey beside a file: url behind whitespace', config: { url: ` ${FILE}`, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, - { name: 'encryptionKey beside :memory:', config: { url: ':memory:', encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, - { name: "encryptionKey beside file: under a forced mode: 'local'", config: { url: FILE, mode: 'local', encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, - { name: 'encryptionKey on a replica: file: + syncUrl', config: { url: FILE, syncUrl: REMOTE, sync: { onConnect: false }, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, - { name: 'encryptionKey on a replica syncing from wss://, bounded by timeoutMs', config: { url: FILE, syncUrl: 'wss://db.example.turso.io', timeoutMs: 5000, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, - { name: "encryptionKey on file: + syncUrl under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', syncUrl: REMOTE, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, - // ORDER: it comes last — every refusal above keeps its own row's verdict beside a key. - { name: 'encryptionKey beside a bare path: the url refusal first', config: { url: './data/app.db', encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'url' }, - { name: 'encryptionKey on a remote url beside syncUrl: the url refusal first', config: { url: REMOTE, syncUrl: REMOTE, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'url' }, - { name: 'encryptionKey on :memory: beside syncUrl: the in-memory replica refusal first', config: { url: ':memory:', syncUrl: REMOTE, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'url' }, - // The spec contract raises BOTH issues here (`sync`, then `encryptionKey`); the constructor throws the first. - { name: 'encryptionKey beside sync with no syncUrl: the sync refusal first', config: { url: FILE, sync: { intervalSeconds: 60 }, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'sync', issues: 2 }, - { name: "encryptionKey under a forced mode: 'replica' with no syncUrl: the mode refusal first", config: { url: FILE, mode: 'replica', encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'mode' }, - { name: "encryptionKey + syncUrl under a forced mode: 'local': the mode refusal first", config: { url: FILE, mode: 'local', syncUrl: REMOTE, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'mode' }, - - // ── the same key beside a local url under a forced mode: 'remote' (#25020, patch round 1) ── - // Judged by where the database lives, not by the mode: `connect()` hands the url to the libSQL - // client the driver builds, which is handed no key. The mirror declares no `mode`, so these rows - // are judged by the constructor and the spec contract only (see the header). - { name: "encryptionKey beside file: under a forced mode: 'remote'", config: { url: FILE, mode: 'remote', encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, - { name: "encryptionKey beside an uppercase FILE: url under a forced mode: 'remote'", config: { url: `FILE:${DIR}/upper-remote-key.db`, mode: 'remote', encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, - { name: "encryptionKey beside file: + timeoutMs under a forced mode: 'remote'", config: { url: FILE, mode: 'remote', timeoutMs: 5000, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, - { name: "encryptionKey beside :memory: under a forced mode: 'remote'", config: { url: ':memory:', mode: 'remote', encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, - { name: "encryptionKey beside file::memory: under a forced mode: 'remote'", config: { url: 'file::memory:', mode: 'remote', encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, - // ORDER against the forced-remote refusals: the spec contract raises BOTH issues (theirs first); the constructor throws the first. - { name: "encryptionKey + syncUrl beside file: under a forced mode: 'remote': the syncUrl refusal first", config: { url: FILE, mode: 'remote', syncUrl: REMOTE, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'syncUrl', issues: 2 }, - { name: "encryptionKey + sync with no syncUrl beside file: under a forced mode: 'remote': the sync refusal first", config: { url: FILE, mode: 'remote', sync: { onConnect: true }, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'sync', issues: 2 }, ]; /** * The rows the constructor refuses on a sync key, on a forced replica with no - * `syncUrl`, on a forced local mode beside a `syncUrl`, or on an - * `encryptionKey` in a local or replica mode: its message is a copy of the - * spec's (#20200, #20437, #20586, #25020). + * `syncUrl`, or on a forced local mode beside a `syncUrl`: its message is a + * copy of the spec's (#20200, #20437, #20586). */ const SYNC_KEY_REFUSALS = ROWS.filter( - (r) => - r.ctor === 'refuse' && - (r.refusedOn === 'syncUrl' || r.refusedOn === 'sync' || r.refusedOn === 'mode' || r.refusedOn === 'encryptionKey'), + (r) => r.ctor === 'refuse' && (r.refusedOn === 'syncUrl' || r.refusedOn === 'sync' || r.refusedOn === 'mode'), ); +/** + * [#25050] The REMOVED `encryptionKey`: every mode, every value, and beside + * every other defect — refused by all three places on `encryptionKey`, in one + * text. `withoutKey` is the same config with no key, which the constructor + * judges as `ROWS` does; it is what makes "refused FIRST" a reading. + */ +const RETIRED_KEY_ROWS: Array<{ name: string; config: Authored; value?: unknown }> = [ + { name: 'beside a libsql:// url (remote: parsed and dropped until #25050)', config: { url: REMOTE } }, + { name: 'beside an https:// url', config: { url: 'https://db.example.turso.io' } }, + { name: 'beside a wss:// url', config: { url: 'wss://db.example.turso.io' } }, + { name: "beside libsql:// under a forced mode: 'remote'", config: { url: REMOTE, mode: 'remote' } }, + { name: 'beside a file: url', config: { url: FILE } }, + { name: 'beside :memory:', config: { url: ':memory:' } }, + { name: 'on a replica: file: + syncUrl', config: { url: FILE, syncUrl: REMOTE, sync: { onConnect: false } } }, + { name: "beside file: under a forced mode: 'local'", config: { url: FILE, mode: 'local' } }, + { name: "beside file: under a forced mode: 'remote'", config: { url: FILE, mode: 'remote' } }, + { name: 'an EMPTY key beside a file: url (no longer "unset")', config: { url: FILE }, value: '' }, + { name: 'an EMPTY key beside a libsql:// url', config: { url: REMOTE }, value: '' }, + // FIRST: each of these is refused on its own, and with the key it meets the retirement. + { name: 'beside a bare path: the retirement first', config: { url: './data/app.db' } }, + { name: 'on a remote url beside syncUrl: the retirement first', config: { url: REMOTE, syncUrl: REMOTE } }, + { name: 'beside sync with no syncUrl: the retirement first', config: { url: FILE, sync: { intervalSeconds: 60 } } }, + { name: 'beside timeoutMs on wss://: the retirement first', config: { url: 'wss://db.example.turso.io', timeoutMs: 5000 } }, + { name: "under a forced mode: 'replica' with no syncUrl: the retirement first", config: { url: FILE, mode: 'replica' } }, +]; + /** * The driver config a datasource loader builds from an authored one — the * reading `buildTursoDriverConfig` / `resolveTursoUrl` in * `@objectstack/service-datasource` perform (not a dependency of this package, * hence spelled here): the url TRIMMED, an empty `syncUrl` unset, and the - * authored `timeoutMs` landing on the driver's `timeout`, and an empty - * `encryptionKey` unset. + * authored `timeoutMs` landing on the driver's `timeout`. [#25050] A stored + * `encryptionKey` is forwarded as it is, whatever it holds, so the + * constructor refuses it (`withKey`). */ function driverConfigOf(authored: Authored): TursoDriverConfig { - const { url, syncUrl, sync, mode, timeoutMs, encryptionKey } = authored; + const { url, syncUrl, sync, mode, timeoutMs } = authored; return { url: url.trim(), ...(syncUrl ? { syncUrl } : {}), ...(sync ? { sync } : {}), ...(mode ? { mode } : {}), ...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}), - ...(encryptionKey ? { encryptionKey } : {}), }; } -function constructorVerdict(authored: Authored): { verdict: 'accept' | 'refuse'; message?: string } { +/** The loader's config plus a stored `encryptionKey` — untyped, as a stored row is. */ +function withKey(authored: Authored, value: unknown): TursoDriverConfig { + return { ...driverConfigOf(authored), encryptionKey: value } as unknown as TursoDriverConfig; +} + +function constructorVerdict( + authored: Authored, + build: (a: Authored) => TursoDriverConfig = driverConfigOf, +): { verdict: 'accept' | 'refuse'; message?: string } { try { - new TursoDriver(driverConfigOf(authored)); + new TursoDriver(build(authored)); return { verdict: 'accept' }; } catch (error) { // A refusal is the ADR-0112 envelope, never some other throw. @@ -309,13 +296,11 @@ describe('turso config: the constructor, the spec contract and this mirror agree // [#20586] The forced-local half of the `mode` rows, floored on its own so // it cannot shrink behind the forced-replica half. expect(ROWS.filter((r) => r.refusedOn === 'mode' && r.config.mode === 'local').length).toBeGreaterThanOrEqual(8); - // [#25020] Both modes of the `encryptionKey` refusal, floored apart so the - // replica half cannot shrink behind the local half. - expect(ROWS.filter((r) => r.refusedOn === 'encryptionKey').length).toBeGreaterThanOrEqual(13); - expect(ROWS.filter((r) => r.refusedOn === 'encryptionKey' && (r.config.syncUrl || r.config.mode === 'replica')).length).toBeGreaterThanOrEqual(3); - // [#25020, patch round 1] The forced-remote local-url half, floored on its own. - expect(ROWS.filter((r) => r.refusedOn === 'encryptionKey' && r.config.mode === 'remote').length).toBeGreaterThanOrEqual(5); - expect(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(38); + // 20 measured at #25050, after the `encryptionKey` rows moved to RETIRED_KEY_ROWS. + expect(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(20); + // [#25050] The removed key, across every mode and beside other defects. + expect(RETIRED_KEY_ROWS.length).toBeGreaterThanOrEqual(15); + expect(RETIRED_KEY_ROWS.filter((r) => /^(libsql|https|wss):/i.test(r.config.url)).length).toBeGreaterThanOrEqual(5); // [#20200] Exactly zero, not a floor: every key the constructor used to // build and ignore is refused at construction now (see the header). expect(ROWS.filter((r) => r.inert).length).toBe(0); @@ -347,12 +332,34 @@ describe('turso config: the constructor, the spec contract and this mirror agree }); }); + // [#25050] The removed key: refused by the constructor, the spec contract + // and this mirror, on `encryptionKey`, alone, in ONE text — whatever else the + // config gets wrong, and whatever mode it names (the mirror strips `mode`, + // but the key is refused before any mode is read). + describe.each(RETIRED_KEY_ROWS)('encryptionKey $name', (row) => { + const value = 'value' in row ? row.value : KEY; + const authored = { ...row.config, encryptionKey: value }; + + it('all three refuse it on `encryptionKey`, alone, in one text', () => { + const spec = schemaVerdict(SpecTursoConfigSchema, authored); + expect(spec.refusedOn, spec.message).toBe('encryptionKey'); + expect(spec.count).toBe(1); + expect(spec.message).toMatch(/^`turso config\.encryptionKey` was removed in @objectstack\/spec 18/); + + expect(schemaVerdict(MirrorTursoConfigSchema, authored)).toEqual(spec); + + const ctor = constructorVerdict(row.config, (a) => withKey(a, value)); + expect(ctor.verdict).toBe('refuse'); + expect(ctor.message).toBe(spec.message); + }); + }); + // [#20200] The two sync refusals, [#20437] the forced-replica refusal and // [#20586] the forced-local one are copies of the spec contract's texts in // `../turso-driver.ts` (the spec keeps them module-local); this is the pin // that holds each copy equal to the schema's issue, byte for byte. describe.each(SYNC_KEY_REFUSALS)('$name', (row) => { - it("the constructor's message is the spec contract's, byte for byte (#20200, #20437, #20586, #25020)", () => { + it("the constructor's message is the spec contract's, byte for byte (#20200, #20437, #20586)", () => { const spec = schemaVerdict(SpecTursoConfigSchema, row.config); expect(spec.refusedOn).toBe(row.refusedOn); expect(spec.message).toBeTypeOf('string'); diff --git a/packages/drivers/driver-turso/src/spec/turso.test.ts b/packages/drivers/driver-turso/src/spec/turso.test.ts index 6ab62e84362..51f5124de9f 100644 --- a/packages/drivers/driver-turso/src/spec/turso.test.ts +++ b/packages/drivers/driver-turso/src/spec/turso.test.ts @@ -60,8 +60,8 @@ describe('TursoConfigSchema', () => { // `libsql://` url, a configuration the driver refuses at construction and // this schema now refuses too. // [#25020] `encryptionKey` left this fixture: the driver never encrypted the - // replica file with it (its local engine has no encryption codec), and a - // local or replica config that declares it is refused now — pinned in + // replica file with it (its local engine has no encryption codec). [#25050] + // It is a tombstone now, refused in every mode — pinned in // `turso-config-constructor-parity.test.ts`. it('should accept config with all fields', () => { const config = TursoConfigSchema.parse({ diff --git a/packages/drivers/driver-turso/src/spec/turso.zod.ts b/packages/drivers/driver-turso/src/spec/turso.zod.ts index 5bc32af4635..ffa07e1e7c8 100644 --- a/packages/drivers/driver-turso/src/spec/turso.zod.ts +++ b/packages/drivers/driver-turso/src/spec/turso.zod.ts @@ -81,6 +81,24 @@ const WASM_RETIRED = + 'the remote arm (`libsql://` / `https://`), which needs none. ' + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; +/** + * The prescription the REMOVED `encryptionKey` raises (#25050, ADR-0049 + * enforce-or-remove). Unlike the three above, it is not this package's own + * wording: it is `@objectstack/spec`'s `TursoConfigSchema` tombstone text, byte + * for byte, and the constructor in `../turso-driver.ts` refuses the key in it + * too, because the one key is judged at three places and an author must meet + * one prescription at all of them. `turso-config-constructor-parity.test.ts` + * holds the three copies equal. + */ +const ENCRYPTION_KEY_RETIRED = + '`turso config.encryptionKey` was removed in @objectstack/spec 18 and @objectstack/driver-turso 18 ' + + '(ADR-0049) — it never encrypted anything, in any mode: a local or embedded-replica file is read and ' + + 'written through a SQLite engine that has no encryption codec, and the driver never handed the key to ' + + 'a remote database, whose storage encryption the server configures. Delete the key. A local file that ' + + 'must be encrypted at rest belongs on an encrypted disk or volume. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for the unset and remote-url cases; ' + + '`--write` applies the ones it can prove, and you delete the key from a local or replica datasource by hand.'; + // ========================================================================== // 2a. Transport coherence — what `new TursoDriver` refuses // ========================================================================== @@ -151,12 +169,11 @@ interface TursoTransportKeys { syncUrl?: unknown; mode?: TursoTransportModeName; timeoutMs?: unknown; - encryptionKey?: unknown; } /** One refusal: the key it sits on and its message. */ interface TursoTransportIssue { - path: 'url' | 'syncUrl' | 'timeoutMs' | 'mode' | 'encryptionKey'; + path: 'url' | 'syncUrl' | 'timeoutMs' | 'mode'; message: string; } @@ -276,26 +293,6 @@ function tursoTransportIssues(cfg: TursoTransportKeys): TursoTransportIssue[] { + "`url: 'file:./data/replica.db'`. For a plain local database, drop `syncUrl` (and `sync`).", }]; } - if (typeof cfg.encryptionKey === 'string' && cfg.encryptionKey.length > 0) { - // #25020. The url and the mode are coherent (every other shape met a - // refusal above), so what the runtime cannot honour is the KEY: the - // local engine has no encryption codec, so the issue sits on - // `encryptionKey`. An empty key is unset, as the loaders read it. - return [{ - path: 'encryptionKey', - message: - `\`encryptionKey\` asks for this datasource's database file to be encrypted, but this datasource is ${arm}, ` - + 'which runs every read and write through a local SQLite engine that has no encryption codec' - + (mode === 'replica' - ? ' (the sync client shares that one file, and a file it encrypted could not be opened by that engine)' - : '') - + ': nothing would encrypt the file, so it would be written in plaintext that any SQLite reader opens ' - + 'without the key — the turso driver refuses this configuration when it starts. Nothing in this driver ' - + 'encrypts a local or replica file. Drop `encryptionKey`, and encrypt the disk or volume the file lives ' - + "on instead, or keep the data in a remote Turso database, whose storage encryption is the server's to " - + "configure, not this key's.", - }]; - } return []; } @@ -324,32 +321,6 @@ function tursoTransportIssues(cfg: TursoTransportKeys): TursoTransportIssue[] { + "file beside `syncUrl`: `url: 'file:./data/replica.db'`.", }); } - if ( - (isFileTursoUrl(url) || url === ':memory:') - && typeof cfg.encryptionKey === 'string' && cfg.encryptionKey.length > 0 - ) { - // #25020, patch round 1. Only a FORCED remote mode reaches here with a - // `file:` url or `:memory:` (with no `mode`, those urls select a local - // engine). The key is judged by where the database lives, not by the - // mode: `connect()` hands that url to the libSQL client this driver - // builds, which is handed no key, so the issue sits on `encryptionKey`, - // last, like the local-engine arm's. - issues.push({ - path: 'encryptionKey', - message: namesInMemoryTursoDatabase(url) - ? "`encryptionKey` asks for this datasource's database to be encrypted, but `url` names an in-memory " - + "database, and `mode: 'remote'` runs every read and write of it through the libSQL client this " - + 'driver builds, which is handed no key: there is no file for the key to encrypt, so it would change ' - + 'nothing — the turso driver refuses this configuration when it starts. Drop `encryptionKey`.' - : "`encryptionKey` asks for this datasource's database file to be encrypted, but `url` is a local " - + "`file:` url, and `mode: 'remote'` runs every read and write of that file through the libSQL client " - + 'this driver builds, which is handed no key: nothing would encrypt the file, so it would be written ' - + 'in plaintext that any SQLite reader opens without the key — the turso driver refuses this ' - + 'configuration when it starts. Nothing in this driver encrypts a local file. Drop `encryptionKey`, ' - + 'and encrypt the disk or volume the file lives on instead, or point `url` at a remote Turso ' - + "database, whose storage encryption is the server's to configure, not this key's.", - }); - } return issues; } @@ -372,21 +343,23 @@ export const TursoConfigSchema = lazySchema(() => z.object({ authToken: z.string().optional().describe('Authentication token for remote database'), /** - * Encryption Key — encrypts nothing, in any mode, and is REFUSED for a local - * or embedded-replica database, and beside a `file:` url or `:memory:` under - * a forced `mode: 'remote'` (#25020; the refusals are the - * `tursoTransportIssues` arms above, in the spec contract's words — this - * mirror declares no `mode`, so it judges such a config as the local - * database its url selects, and refuses it with that arm's text). + * Tombstone for the REMOVED `encryptionKey` (#25050, ADR-0049 + * enforce-or-remove). + * + * It promised to encrypt the local database file at rest with AES-256 and + * was honoured in no mode: the driver reads and writes that file through + * better-sqlite3, which has no encryption codec (so #25020 refused it + * there), and it never handed the key to the libSQL client it builds for a + * remote url, where the key was parsed and dropped. `z.never()` rather than + * a bare deletion for the reason `timeout` below spells out: this shape is a + * plain `z.object`, and a deletion would strip the key in silence. * - * It used to promise to encrypt the local database file at rest with - * AES-256. The driver reads and writes that file through better-sqlite3, - * which has no encryption codec, so the file was written in plaintext that - * any SQLite reader opened without the key; an embedded replica handed it to - * its sync client only. The driver does not forward it to a remote database - * either, whose storage encryption is the server's. + * The text is `@objectstack/spec`'s `TursoConfigSchema` tombstone on the + * same key, byte for byte, and the constructor's refusal in + * `../turso-driver.ts`; `turso-config-constructor-parity.test.ts` holds the + * three equal. */ - encryptionKey: z.string().optional().describe("Not honoured in any mode: refused for a local or replica datasource, whose SQLite engine cannot encrypt the file, and for a file: url or :memory: under a forced remote mode, whose libSQL client is handed no key; a remote database's storage encryption is the server's, not set by this key"), + encryptionKey: z.never({ error: () => ENCRYPTION_KEY_RETIRED }).optional().describe(`[REMOVED] ${ENCRYPTION_KEY_RETIRED}`), /** * Concurrency Limit. diff --git a/packages/drivers/driver-turso/src/turso-driver-encryption-key-retired.test.ts b/packages/drivers/driver-turso/src/turso-driver-encryption-key-retired.test.ts new file mode 100644 index 00000000000..309695eff4b --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-driver-encryption-key-retired.test.ts @@ -0,0 +1,213 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25050] `encryptionKey` is REMOVED, and `new TursoDriver()` refuses it in + * every mode, for any value — the ADR-0049 answer to a declared key the + * runtime honoured nowhere. + * + * # What was measured (the reading this retirement stands on) + * + * On a local or replica datasource every read and write runs through Knex on + * better-sqlite3 13.0.3 (SQLite 3.53.4): no codec compile option is set and + * `PRAGMA key` is a silent no-op. Before #25020 a local datasource with the + * key constructed, connected, wrote and read back, and its file started + * `SQLite format 3` and opened in plain better-sqlite3 with no key; #25020 + * refused the key there. On a remote url, `createRemoteClient` builds + * `createClient({ url, authToken, concurrency, fetch })` and never read the + * key: `new TursoDriver({ url: 'libsql://…', encryptionKey })` constructed in + * remote mode and dropped it (#25020's dev report). `@libsql/client`'s own + * remote client refuses such a key outright (`ENCRYPTION_KEY_NOT_SUPPORTED`). + * + * # What this file pins + * + * The refusal in every mode as the ADR-0112 envelope (`code` + `status`) plus + * the message's first sentence; that it fires before any database file is + * opened, and FIRST, ahead of every other refusal a config also earns; that + * an empty or non-string value is refused too; that the typed channel is a + * compile error; and that a datasource with no key still connects, writes and + * reads back exactly as before. The message equals the spec contract's issue + * and the mirror's, byte for byte; that equality is pinned in + * `spec/turso-config-constructor-parity.test.ts`, not here. + * + * # Reverse verification: direction predicted before it was run + * + * Remove the refusal from the constructor and every remote-url refusal case + * here goes RED (the constructor returns a remote driver); the local and + * replica cases go RED too (the driver builds, and connect would write the + * file). Every control stays GREEN. Measured; see the PR. + */ + +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, describe, expect, it } from 'vitest'; +import { createTursoDriver } from './index.js'; +import { TursoDriver, type TursoDriverConfig } from './turso-driver.js'; + +type Refusal = Error & { code?: string; status?: number }; + +/** The error `build` threw, or `null` when it returned. */ +function refusalOf(build: () => unknown): Refusal | null { + try { + build(); + return null; + } catch (error) { + return error as Refusal; + } +} + +const DIR = mkdtempSync(join(tmpdir(), 'turso-25050-')); +afterAll(() => rmSync(DIR, { recursive: true, force: true })); + +let seq = 0; +/** A fresh database path in its own name; the file does not exist yet. */ +const nextPath = () => join(DIR, `db-${++seq}.db`); + +const PRIMARY = 'libsql://primary.example.invalid'; +/** A test value only — never a real key. */ +const KEY = 'test-key-25050'; + +const FIRST_SENTENCE = + '`turso config.encryptionKey` was removed in @objectstack/spec 18 and @objectstack/driver-turso 18 ' + + '(ADR-0049) — it never encrypted anything, in any mode: a local or embedded-replica file is read and ' + + 'written through a SQLite engine that has no encryption codec, and the driver never handed the key to ' + + 'a remote database, whose storage encryption the server configures.'; + +/** + * A config carrying the removed key. The slot is typed `never`, so only an + * untyped value reaches the constructor — a stored datasource row through + * the loaders — and this is how a test spells one. + */ +const withKey = (config: TursoDriverConfig, value: unknown = KEY): TursoDriverConfig => + ({ ...config, encryptionKey: value }) as unknown as TursoDriverConfig; + +/** A client that never touches a network: the replica arm takes it instead of building one. */ +const offlineClient = () => ({ close() {}, sync: async () => ({}) }) as unknown as TursoDriverConfig['client']; + +function expectRetired(refusal: Refusal | null, context: string): void { + expect(refusal, context).not.toBeNull(); + expect(refusal!.code, context).toBe('VALIDATION_ERROR'); + expect(refusal!.status, context).toBe(400); + expect(refusal!.message.startsWith(FIRST_SENTENCE), refusal!.message).toBe(true); +} + +describe('`encryptionKey` is refused at construction in every mode', () => { + it.each<[string, TursoDriverConfig]>([ + ['a libsql:// url (remote: the key used to be parsed and dropped)', { url: 'libsql://db.example.invalid' }], + ['an https:// url', { url: 'https://db.example.invalid' }], + ['a wss:// url', { url: 'wss://db.example.invalid' }], + ["a libsql:// url under a forced mode: 'remote'", { url: 'libsql://db.example.invalid', mode: 'remote' }], + ['a pre-built remote client', { url: 'libsql://db.example.invalid', client: offlineClient() }], + ])('%s: VALIDATION_ERROR / 400', (_name, config) => { + expectRetired(refusalOf(() => new TursoDriver(withKey(config))), _name); + }); + + it.each<[string, (path: string) => TursoDriverConfig]>([ + ['a local file: url', (path) => ({ url: `file:${path}` })], + ['an uppercase FILE: url', (path) => ({ url: `FILE:${path}` })], + ["a forced mode: 'local'", (path) => ({ url: `file:${path}`, mode: 'local' })], + ['a replica, the driver building the sync client', (path) => ({ url: `file:${path}`, syncUrl: PRIMARY, sync: { onConnect: false } })], + ['a replica, a supplied client', (path) => ({ url: `file:${path}`, syncUrl: PRIMARY, client: offlineClient(), sync: { onConnect: false } })], + ["a file: url under a forced mode: 'remote'", (path) => ({ url: `file:${path}`, mode: 'remote' })], + ])('%s: VALIDATION_ERROR / 400, and no file is opened', (name, configOf) => { + const path = nextPath(); + expectRetired(refusalOf(() => new TursoDriver(withKey(configOf(path)))), name); + expect(existsSync(path)).toBe(false); + }); + + it.each([':memory:', 'file::memory:'])('%s: refused the same way', (url) => { + expectRetired(refusalOf(() => new TursoDriver(withKey({ url }))), url); + }); + + it.each<[string, unknown]>([ + ['an empty string (no longer "unset")', ''], + ['a long key', 'a'.repeat(32)], + ['null', null], + ['a number', 0], + ])('any value — %s — is refused', (name, value) => { + expectRetired(refusalOf(() => new TursoDriver(withKey({ url: 'libsql://db.example.invalid' }, value))), name); + }); + + it('FIRST: a config wrong in another way as well meets the retirement, as the spec contract answers it', () => { + for (const config of [ + { url: './data/app.db' }, + { url: 'libsql://db.example.invalid', syncUrl: PRIMARY }, + { url: `file:${nextPath()}`, sync: { intervalSeconds: 60 } }, + { url: `file:${nextPath()}`, mode: 'replica' as const }, + { url: 'wss://db.example.invalid', timeout: 5000 }, + ]) { + expectRetired(refusalOf(() => new TursoDriver(withKey(config))), JSON.stringify(config)); + } + }); + + it('createTursoDriver() is the same constructor, and the message names no url and no key', () => { + const refusal = refusalOf( + () => createTursoDriver(withKey({ url: 'libsql://private-host.example.invalid' }, 'private-key-value')), + ); + expectRetired(refusal, 'createTursoDriver'); + expect(refusal!.message).not.toContain('private-host'); + expect(refusal!.message).not.toContain('private-key-value'); + }); + + it('is a tsc error at a typed construction site', () => { + const refusal = refusalOf( + // @ts-expect-error — `TursoDriverConfig.encryptionKey` is `never`: no value is assignable. + () => new TursoDriver({ url: 'libsql://db.example.invalid', encryptionKey: KEY }), + ); + expectRetired(refusal, 'typed'); + }); +}); + +describe('CONTROLS — a datasource without the key works exactly as before', () => { + const Database = createRequire(import.meta.url)('better-sqlite3') as new ( + filename: string, + options?: { readonly?: boolean }, + ) => { prepare(sql: string): { all(): Array> }; close(): void }; + const OBJECT = { name: 'probe', fields: { v: { type: 'text' } } }; + + /** Write one row through the driver and read it back through the driver. */ + async function roundTrip(driver: TursoDriver): Promise { + await driver.connect(); + await driver.syncSchema('probe', OBJECT); + await driver.create('probe', { v: 'row-25050' }); + const rows = (await driver.find('probe', {})) as Array<{ v: unknown }>; + await driver.disconnect(); + return rows.map((row) => row.v); + } + + /** The file as any SQLite reader sees it: its header and the row, with no key. */ + function readPlain(path: string) { + const db = new Database(path, { readonly: true }); + try { + return { + header: readFileSync(path).subarray(0, 15).toString('latin1'), + rows: db.prepare('select v from probe').all().map((row) => row.v), + }; + } finally { + db.close(); + } + } + + it.each<[string, (path: string) => TursoDriverConfig]>([ + ['a local file: url', (path) => ({ url: `file:${path}` })], + ['a replica (a supplied client)', (path) => ({ + url: `file:${path}`, syncUrl: PRIMARY, client: offlineClient(), sync: { onConnect: false }, + })], + ])('%s: constructs, connects, writes and reads back', async (_name, configOf) => { + const path = nextPath(); + const driver = new TursoDriver(configOf(path)); + + expect(await roundTrip(driver)).toEqual(['row-25050']); + expect(readPlain(path)).toEqual({ header: 'SQLite format 3', rows: ['row-25050'] }); + }); + + it.each<[string, TursoDriverConfig]>([ + ['a libsql:// url', { url: 'libsql://db.example.invalid' }], + ["a file: url under a forced mode: 'remote'", { url: `file:${join(DIR, 'remote-no-key.db')}`, mode: 'remote' }], + [':memory: under a forced mode: \'remote\'', { url: ':memory:', mode: 'remote' }], + ])('%s: still constructs in remote mode', (_name, config) => { + expect(new TursoDriver(config).transportMode).toBe('remote'); + }); +}); diff --git a/packages/drivers/driver-turso/src/turso-driver-forced-local-with-sync-url-refusal.test.ts b/packages/drivers/driver-turso/src/turso-driver-forced-local-with-sync-url-refusal.test.ts index c60f4e19304..987c6615c34 100644 --- a/packages/drivers/driver-turso/src/turso-driver-forced-local-with-sync-url-refusal.test.ts +++ b/packages/drivers/driver-turso/src/turso-driver-forced-local-with-sync-url-refusal.test.ts @@ -30,7 +30,7 @@ * * - The refusal as the ADR-0112 envelope (`code` + `status`) plus its first * sentence and both ways out, on a `file:` url in any letter case, on an - * in-memory url, beside `sync`, `timeout` and `encryptionKey`, and beside a + * in-memory url, beside `sync` and `timeout`, and beside a * supplied client (refused before any client work: the client is never * synced). The full message is the spec contract's issue text byte for byte; * that equality is pinned in `spec/turso-config-constructor-parity.test.ts`, @@ -112,7 +112,6 @@ describe("a forced `mode: 'local'` beside a `syncUrl` — refused at constructio ['file::memory:', () => ({ url: 'file::memory:', mode: 'local', syncUrl: PRIMARY })], ['beside sync', () => ({ url: files.next(), mode: 'local', syncUrl: PRIMARY, sync: { intervalSeconds: 60, onConnect: true } })], ['beside a timeout', () => ({ url: files.next(), mode: 'local', syncUrl: PRIMARY, timeout: 5000 })], - ['beside an encryptionKey', () => ({ url: files.next(), mode: 'local', syncUrl: PRIMARY, encryptionKey: 'aes-256-key' })], ])("%s: VALIDATION_ERROR / 400, in the spec contract's words", (_name, config) => { const refusal = refusalOf(() => new TursoDriver(config())); diff --git a/packages/drivers/driver-turso/src/turso-driver-forced-replica-without-sync-url-refusal.test.ts b/packages/drivers/driver-turso/src/turso-driver-forced-replica-without-sync-url-refusal.test.ts index 444ab3a24be..05f30c30238 100644 --- a/packages/drivers/driver-turso/src/turso-driver-forced-replica-without-sync-url-refusal.test.ts +++ b/packages/drivers/driver-turso/src/turso-driver-forced-replica-without-sync-url-refusal.test.ts @@ -92,7 +92,6 @@ describe("a forced `mode: 'replica'` with no `syncUrl` — refused at constructi ['an uppercase FILE: url', () => ({ url: upperFile(files.next()), mode: 'replica' })], ['an empty syncUrl, which is unset', () => ({ url: files.next(), mode: 'replica', syncUrl: '' })], ['beside a timeout', () => ({ url: files.next(), mode: 'replica', timeout: 5000 })], - ['beside an encryptionKey', () => ({ url: files.next(), mode: 'replica', encryptionKey: 'aes-256-key' })], ])("%s: VALIDATION_ERROR / 400, in the spec contract's words", (_name, config) => { const refusal = refusalOf(() => new TursoDriver(config())); diff --git a/packages/drivers/driver-turso/src/turso-driver-local-encryption-key-refusal.test.ts b/packages/drivers/driver-turso/src/turso-driver-local-encryption-key-refusal.test.ts deleted file mode 100644 index 052fe45b36d..00000000000 --- a/packages/drivers/driver-turso/src/turso-driver-local-encryption-key-refusal.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#25020] A non-empty `encryptionKey` on the local engine is refused at - * construction, in a local mode and in an embedded-replica mode alike. The - * ADR-0049 answer to a declared protection the runtime never applied. - * - * # What was measured (the reading this refusal stands on) - * - * On this package's source before the change, with a test key only: - * - * ``` - * local (file: url + encryptionKey) -> constructs, connects, writes and reads back; - * the file starts `SQLite format 3`, and plain - * better-sqlite3 read-only, with no key, reads the row - * replica (+ syncUrl, a supplied client) -> the same: the key reaches nothing - * replica (+ syncUrl, the driver builds the sync client) -> the Knex base writes the file in plaintext - * first; the key reaches only `@libsql/client` - * ``` - * - * The engine every read and write goes through is better-sqlite3 13.0.3 - * (SQLite 3.53.4): no codec compile option, and `PRAGMA key` is a silent - * no-op. Honouring the key would mean replacing that engine, so the key is - * refused instead (the seat ruling on #25020). - * - * # What this file pins - * - * The refusal per mode as the ADR-0112 envelope (`code` + `status`) plus the - * message's first sentence, which names the mode; that the refusal fires - * before any database file is opened; and that a datasource with no key (or - * an empty one, which is unset) connects, writes and reads back exactly as - * before, its file a plain SQLite database. The message equals the spec - * contract's issue byte for byte; that equality is pinned in - * `spec/turso-config-constructor-parity.test.ts`, not here. - * - * [#25020, patch round 1] The same key beside a `file:` url or `:memory:` under - * a forced `mode: 'remote'` is refused too, in its own words: that mode skipped - * every local-engine refusal, and `connect()` handed the local url to the - * libSQL client the driver builds, which is handed no key. Its controls: the - * same shape with no key or an empty one, and a `libsql://` url with a key, - * still construct. - * - * The replica pins need no sync server: the refusal fires at construction, - * before any client is built. A replica with the key and a REACHABLE sync - * server was not measured — no libSQL sync server runs in this package's test - * environment, and the native binding's default sync path speaks the server's - * replication protocol, which nothing here can stand in for. - * - * # Reverse verification: direction predicted before it was run - * - * Remove the refusal from the constructor and every refusal case here goes - * RED (the constructor returns a driver, and the file is created on connect). - * Every control stays GREEN. Measured; see the PR. - */ - -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterAll, describe, expect, it } from 'vitest'; -import { createTursoDriver } from './index.js'; -import { TursoDriver, type TursoDriverConfig } from './turso-driver.js'; - -type Refusal = Error & { code?: string; status?: number }; - -/** The error `build` threw, or `null` when it returned. */ -function refusalOf(build: () => unknown): Refusal | null { - try { - build(); - return null; - } catch (error) { - return error as Refusal; - } -} - -const DIR = mkdtempSync(join(tmpdir(), 'turso-25020-')); -afterAll(() => rmSync(DIR, { recursive: true, force: true })); - -let seq = 0; -/** A fresh database path in its own name; the file does not exist yet. */ -const nextPath = () => join(DIR, `db-${++seq}.db`); - -const PRIMARY = 'libsql://primary.example.invalid'; -/** A test value only — never a real key. */ -const KEY = 'test-key-25020'; - -const LOCAL_FIRST_SENTENCE = - "`encryptionKey` asks for this datasource's database file to be encrypted, but this datasource is a local " + - 'database, which runs every read and write through a local SQLite engine that has no encryption codec: ' + - 'nothing would encrypt the file, so it would be written in plaintext that any SQLite reader opens without ' + - 'the key — the turso driver refuses this configuration when it starts.'; -const REPLICA_FIRST_SENTENCE = - "`encryptionKey` asks for this datasource's database file to be encrypted, but this datasource is an " + - 'embedded replica, which runs every read and write through a local SQLite engine that has no encryption ' + - 'codec (the sync client shares that one file, and a file it encrypted could not be opened by that ' + - 'engine): nothing would encrypt the file, so it would be written in plaintext that any SQLite reader ' + - 'opens without the key — the turso driver refuses this configuration when it starts.'; - -const REMOTE_FILE_FIRST_SENTENCE = - "`encryptionKey` asks for this datasource's database file to be encrypted, but `url` is a local `file:` " + - "url, and `mode: 'remote'` runs every read and write of that file through the libSQL client this driver " + - 'builds, which is handed no key: nothing would encrypt the file, so it would be written in plaintext that ' + - 'any SQLite reader opens without the key — the turso driver refuses this configuration when it starts.'; -const REMOTE_MEMORY_FIRST_SENTENCE = - "`encryptionKey` asks for this datasource's database to be encrypted, but `url` names an in-memory " + - "database, and `mode: 'remote'` runs every read and write of it through the libSQL client this driver " + - 'builds, which is handed no key: there is no file for the key to encrypt, so it would change nothing — ' + - 'the turso driver refuses this configuration when it starts.'; - -/** A client that never touches a network: the replica arm takes it instead of building one. */ -const offlineClient = () => ({ close() {}, sync: async () => ({}) }) as unknown as TursoDriverConfig['client']; - -describe('a local database: `encryptionKey` is refused at construction', () => { - it.each<[string, (path: string) => TursoDriverConfig]>([ - ['a file: url', (path) => ({ url: `file:${path}`, encryptionKey: KEY })], - ['an uppercase FILE: url', (path) => ({ url: `FILE:${path}`, encryptionKey: KEY })], - ["a forced mode: 'local'", (path) => ({ url: `file:${path}`, mode: 'local', encryptionKey: KEY })], - ])('%s: VALIDATION_ERROR / 400, naming a local database, and no file is opened', (_name, configOf) => { - const path = nextPath(); - const refusal = refusalOf(() => new TursoDriver(configOf(path))); - - expect(refusal).not.toBeNull(); - expect(refusal!.code).toBe('VALIDATION_ERROR'); - expect(refusal!.status).toBe(400); - expect(refusal!.message.startsWith(LOCAL_FIRST_SENTENCE), refusal!.message).toBe(true); - expect(existsSync(path)).toBe(false); - }); - - it(':memory: is refused the same way', () => { - const refusal = refusalOf(() => new TursoDriver({ url: ':memory:', encryptionKey: KEY })); - - expect(refusal?.code).toBe('VALIDATION_ERROR'); - expect(refusal?.status).toBe(400); - expect(refusal!.message.startsWith(LOCAL_FIRST_SENTENCE)).toBe(true); - }); - - it('createTursoDriver() is the same constructor, and refuses the same config', () => { - const refusal = refusalOf(() => createTursoDriver({ url: `file:${nextPath()}`, encryptionKey: KEY })); - - expect(refusal?.code).toBe('VALIDATION_ERROR'); - expect(refusal?.status).toBe(400); - }); -}); - -describe('an embedded replica: `encryptionKey` is refused at construction', () => { - it.each<[string, (path: string) => TursoDriverConfig]>([ - ['file: + syncUrl, the driver building the sync client', (path) => ({ - url: `file:${path}`, syncUrl: PRIMARY, sync: { onConnect: false }, encryptionKey: KEY, - })], - ['file: + syncUrl, a supplied client', (path) => ({ - url: `file:${path}`, syncUrl: PRIMARY, client: offlineClient(), sync: { onConnect: false }, encryptionKey: KEY, - })], - ["file: + syncUrl under a forced mode: 'replica'", (path) => ({ - url: `file:${path}`, mode: 'replica', syncUrl: PRIMARY, sync: { onConnect: false }, encryptionKey: KEY, - })], - ])('%s: VALIDATION_ERROR / 400, naming an embedded replica, and no file is opened', (_name, configOf) => { - const path = nextPath(); - const refusal = refusalOf(() => new TursoDriver(configOf(path))); - - expect(refusal).not.toBeNull(); - expect(refusal!.code).toBe('VALIDATION_ERROR'); - expect(refusal!.status).toBe(400); - expect(refusal!.message.startsWith(REPLICA_FIRST_SENTENCE), refusal!.message).toBe(true); - expect(existsSync(path)).toBe(false); - }); - - it('the message names no url and no key', () => { - const refusal = refusalOf( - () => new TursoDriver({ url: `file:${join(DIR, 'private-dir.db')}`, syncUrl: 'libsql://private-host.example.invalid', encryptionKey: 'private-key-value' }), - ); - - expect(refusal?.code).toBe('VALIDATION_ERROR'); - expect(refusal!.message).not.toContain('private-dir'); - expect(refusal!.message).not.toContain('private-host'); - expect(refusal!.message).not.toContain('private-key-value'); - }); -}); - -// [#25020, patch round 1] A forced `mode: 'remote'` answers first in `detectMode`, so this shape -// skipped every local-engine refusal, and `connect()` handed the local url to the libSQL client the -// driver builds, with no key. Judged by where the database lives, not by the mode. -describe("a local url under a forced mode: 'remote': `encryptionKey` is refused at construction", () => { - it.each<[string, (path: string) => TursoDriverConfig]>([ - ['a file: url', (path) => ({ url: `file:${path}`, mode: 'remote', encryptionKey: KEY })], - ['an uppercase FILE: url', (path) => ({ url: `FILE:${path}`, mode: 'remote', encryptionKey: KEY })], - ['a file: url beside timeout', (path) => ({ url: `file:${path}`, mode: 'remote', timeout: 5000, encryptionKey: KEY })], - ])('%s: VALIDATION_ERROR / 400, naming the forced remote mode, and no file is opened', (_name, configOf) => { - const path = nextPath(); - const refusal = refusalOf(() => new TursoDriver(configOf(path))); - - expect(refusal).not.toBeNull(); - expect(refusal!.code).toBe('VALIDATION_ERROR'); - expect(refusal!.status).toBe(400); - expect(refusal!.message.startsWith(REMOTE_FILE_FIRST_SENTENCE), refusal!.message).toBe(true); - expect(existsSync(path)).toBe(false); - }); - - it.each([':memory:', 'file::memory:'])('%s: refused in the in-memory words', (url) => { - const refusal = refusalOf(() => new TursoDriver({ url, mode: 'remote', encryptionKey: KEY })); - - expect(refusal?.code).toBe('VALIDATION_ERROR'); - expect(refusal?.status).toBe(400); - expect(refusal!.message.startsWith(REMOTE_MEMORY_FIRST_SENTENCE), refusal!.message).toBe(true); - }); - - it('createTursoDriver() refuses the same config, and the message names no url and no key', () => { - const refusal = refusalOf( - () => createTursoDriver({ url: `file:${join(DIR, 'private-dir.db')}`, mode: 'remote', encryptionKey: 'private-key-value' }), - ); - - expect(refusal?.code).toBe('VALIDATION_ERROR'); - expect(refusal?.status).toBe(400); - expect(refusal!.message).not.toContain('private-dir'); - expect(refusal!.message).not.toContain('private-key-value'); - }); - - it.each<[string, TursoDriverConfig]>([ - ['the same file: url with no key', { url: `file:${join(DIR, 'remote-no-key.db')}`, mode: 'remote' }], - ['the same file: url with an empty key (unset)', { url: `file:${join(DIR, 'remote-empty-key.db')}`, mode: 'remote', encryptionKey: '' }], - [':memory: with no key', { url: ':memory:', mode: 'remote' }], - ['a libsql:// url with a key (a remote server\'s storage: not judged by this card)', { url: 'libsql://db.example.invalid', encryptionKey: KEY }], - ["a libsql:// url with a key under a forced mode: 'remote'", { url: 'libsql://db.example.invalid', mode: 'remote', encryptionKey: KEY }], - ])('CONTROL: %s still constructs, in remote mode', (_name, config) => { - expect(new TursoDriver(config).transportMode).toBe('remote'); - }); -}); - -describe('CONTROLS — a datasource without a key works exactly as before', () => { - const Database = createRequire(import.meta.url)('better-sqlite3') as new ( - filename: string, - options?: { readonly?: boolean }, - ) => { prepare(sql: string): { all(): Array> }; close(): void }; - const OBJECT = { name: 'probe', fields: { v: { type: 'text' } } }; - - /** Write one row through the driver and read it back through the driver. */ - async function roundTrip(driver: TursoDriver): Promise { - await driver.connect(); - await driver.syncSchema('probe', OBJECT); - await driver.create('probe', { v: 'row-25020' }); - const rows = (await driver.find('probe', {})) as Array<{ v: unknown }>; - await driver.disconnect(); - return rows.map((row) => row.v); - } - - /** The file as any SQLite reader sees it: its header and the row, with no key. */ - function readPlain(path: string) { - const db = new Database(path, { readonly: true }); - try { - return { - header: readFileSync(path).subarray(0, 15).toString('latin1'), - rows: db.prepare('select v from probe').all().map((row) => row.v), - }; - } finally { - db.close(); - } - } - - it.each<[string, (path: string) => TursoDriverConfig]>([ - ['a local file: url with no key', (path) => ({ url: `file:${path}` })], - ['a local file: url with an empty key (unset)', (path) => ({ url: `file:${path}`, encryptionKey: '' })], - ['a replica with no key (a supplied client)', (path) => ({ - url: `file:${path}`, syncUrl: PRIMARY, client: offlineClient(), sync: { onConnect: false }, - })], - ])('%s: constructs, connects, writes and reads back; the file is a plain SQLite database', async (_name, configOf) => { - const path = nextPath(); - const driver = new TursoDriver(configOf(path)); - - expect(await roundTrip(driver)).toEqual(['row-25020']); - expect(readPlain(path)).toEqual({ header: 'SQLite format 3', rows: ['row-25020'] }); - }); -}); diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index 6515a301fe6..ad933a4d535 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -117,17 +117,18 @@ export interface TursoDriverConfig { authToken?: string; /** - * Encrypts nothing, in any mode. A non-empty key in a local or replica mode - * is REFUSED at construction (`VALIDATION_ERROR` / 400): this driver reads - * and writes the local file through better-sqlite3, which has no encryption - * codec, so the file would be written in plaintext that any SQLite reader - * opens without the key. Drop it, and encrypt the disk or volume the file - * lives on instead. A `file:` url or `:memory:` under a forced - * `mode: 'remote'` is refused the same way: the libSQL client this driver - * builds for it is handed no key. A remote database's storage encryption is - * the server's; this driver does not forward the key to it. + * REMOVED (#25050, ADR-0049 enforce-or-remove) — typed `never`, so a typed + * caller fails to compile, and refused at construction in every mode + * (`VALIDATION_ERROR` / 400) for a value that arrives untyped (a stored + * datasource row, through the datasource loaders). It promised to encrypt + * the local database file and was honoured in no mode: this driver reads + * and writes a local or replica file through better-sqlite3, which has no + * encryption codec, and never handed the key to the libSQL client it builds + * for a remote url. Delete it; a local file that must be encrypted at rest + * belongs on an encrypted disk or volume. See + * {@link ENCRYPTION_KEY_RETIRED_REFUSAL}. */ - encryptionKey?: string; + encryptionKey?: never; /** * Maximum concurrent requests to the remote database. @@ -1463,123 +1464,63 @@ const LOCAL_MODE_WITH_SYNC_URL_REFUSAL = "`url: 'file:./data/replica.db'`. For a plain local database, drop `syncUrl` (and `sync`)."; /** - * [#25020] A non-empty `encryptionKey` on the local engine — refused at - * construction, in a local mode and in a replica mode alike. + * [#25050] The REMOVED `encryptionKey` — refused at construction, in every + * mode, for any value (an empty string included, as the spec contract and + * this package's mirror refuse it). * * The key promised "AES-256 encryption key for the local database file", and - * nothing that touches that file could honour it. Every read and write of a - * local or replica datasource runs through Knex on better-sqlite3, and - * better-sqlite3 has no encryption codec: measured on better-sqlite3 13.0.3 - * (SQLite 3.53.4), no codec compile option is set and `PRAGMA key` is a - * silent no-op. So, measured on this package's source before this refusal: + * nothing this driver builds ever honoured it: * - * ``` - * local (file: url + encryptionKey) -> constructs, connects, writes; the file - * starts `SQLite format 3`, and plain - * better-sqlite3 reads the row back with no key - * replica (+ syncUrl, a supplied client) -> the same: the key reaches nothing - * replica (+ syncUrl, the driver builds the sync client) -> `super.connect()` writes the file in plaintext - * first; only then is `@libsql/client` handed the - * key (with no reachable sync server here, its - * construction fails to connect) - * @libsql/client + the key, over that plaintext file -> SQLITE_NOTADB - * this driver's replica face over a file @libsql/client - * encrypted with the key -> DATABASE_ERROR (SQLITE_NOTADB) - * ``` - * - * The native libSQL binding DOES encrypt a local file under the key, but it is - * not the engine this driver reads and writes through: honouring the key - * would mean replacing the Knex / better-sqlite3 path with another engine, - * and the ruling on #25020 refuses the key instead of swapping engines. A - * replica is refused for the same reason as a local database, because its - * sync client and its local engine share one file: whichever opens it with the - * key, the other cannot read it, and the engine that serves every read and - * write never encrypts it. + * - a local or replica datasource runs every read and write through Knex on + * better-sqlite3, which has no encryption codec: measured on + * better-sqlite3 13.0.3 (SQLite 3.53.4), no codec compile option is set and + * `PRAGMA key` is a silent no-op. Before #25020 such a datasource + * constructed, connected and wrote a file that starts `SQLite format 3`, + * and plain better-sqlite3 read its rows back with no key. A replica's + * sync client and its local engine share one file, so whichever opens it + * with the key, the other cannot read it (SQLITE_NOTADB). #25020 refused + * the key there, and beside a `file:` url or `:memory:` under a forced + * `mode: 'remote'`, whose libSQL client was handed no key either; + * - on a remote url, {@link TursoDriver.createRemoteClient} builds + * `createClient({ url, authToken, concurrency, fetch })` and never read the + * key, so it was parsed and dropped. `@libsql/client`'s own remote client + * refuses one outright (`ENCRYPTION_KEY_NOT_SUPPORTED`): a remote + * database's storage encryption is the server's. * - * Raised BEFORE `super()`, so no file is opened, and LAST among the - * local-engine refusals: a url the engine cannot open, a sync key nothing - * would read and a forced mode the driver would ignore each keep their own - * message. An empty key is unset, as the datasource loaders read it - * (`buildTursoDriverConfig` drops it). A forced `mode: 'remote'` on a - * `file:` url or `:memory:` meets {@link remoteModeLocalEncryptionKeyRefusal} - * instead; a remote url is not judged here. + * Honoured in no mode, so the key is retired (ADR-0049) rather than refused + * mode by mode. Raised FIRST, BEFORE `super()`: the spec contract refuses the + * key at the key itself, ahead of every transport refusal, so a config that + * carries one meets this message whatever else is wrong with it, here and at + * authoring alike. * - * ⚠️ The text is `@objectstack/spec`'s `TursoConfigSchema` refusal on - * `encryptionKey`, byte for byte, held equal by - * `spec/turso-config-constructor-parity.test.ts`. Edit it there first. + * ⚠️ The text is `@objectstack/spec`'s `TursoConfigSchema` tombstone on + * `encryptionKey`, byte for byte, and this package's mirror carries the same + * copy; `spec/turso-config-constructor-parity.test.ts` holds the three equal. + * Edit it in the spec first. */ -function localEncryptionKeyRefusal(mode: 'local' | 'replica'): string { - const arm = mode === 'replica' ? 'an embedded replica' : 'a local database'; - return ( - `\`encryptionKey\` asks for this datasource's database file to be encrypted, but this datasource is ${arm}, ` + - 'which runs every read and write through a local SQLite engine that has no encryption codec' + - (mode === 'replica' - ? ' (the sync client shares that one file, and a file it encrypted could not be opened by that engine)' - : '') + - ': nothing would encrypt the file, so it would be written in plaintext that any SQLite reader opens ' + - 'without the key — the turso driver refuses this configuration when it starts. Nothing in this driver ' + - 'encrypts a local or replica file. Drop `encryptionKey`, and encrypt the disk or volume the file lives ' + - "on instead, or keep the data in a remote Turso database, whose storage encryption is the server's to " + - "configure, not this key's." - ); -} +// Written with LEADING `+`, unlike this file's other messages: the house-sentence +// pin (`retired-key-migrate-sentence.test.ts` in `@objectstack/spec`) scans this +// package and joins string seams in that spelling. +const ENCRYPTION_KEY_RETIRED_REFUSAL = + '`turso config.encryptionKey` was removed in @objectstack/spec 18 and @objectstack/driver-turso 18 ' + + '(ADR-0049) — it never encrypted anything, in any mode: a local or embedded-replica file is read and ' + + 'written through a SQLite engine that has no encryption codec, and the driver never handed the key to ' + + 'a remote database, whose storage encryption the server configures. Delete the key. A local file that ' + + 'must be encrypted at rest belongs on an encrypted disk or volume. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for the unset and remote-url cases; ' + + '`--write` applies the ones it can prove, and you delete the key from a local or replica datasource by hand.'; /** - * [#25020, patch round 1] The same key beside a `file:` url or `:memory:` under - * a FORCED `mode: 'remote'` — refused at construction too. - * - * {@link TursoDriver.detectMode} answers the forced mode first, so this shape - * skips every local-engine refusal: `toKnexConfig`'s remote arm opens nothing, - * and `connect()` hands the url to {@link TursoDriver.createRemoteClient}, - * which builds `createClient({ url, authToken, concurrency })` with no key — - * and `@libsql/client` opens the local file for a `file:` url (the reading in - * {@link startsWithScheme}'s docblock; the parity table's row "file: under a - * forced mode: 'remote'" and `turso-driver-unrecognised-url-refusal.test.ts` - * pin the shape as a supported one). Every read and write then runs through - * the remote transport over that file, in plaintext. Honouring the key here - * is not the route: it would make a file's encryption depend on a transport - * override, a capability nothing pulls, and the key is proposed for - * retirement. So it is refused, in its own words — one text for a file, one - * for an in-memory database, which has no file to encrypt. - * - * Raised after every forced-remote refusal above, so a `syncUrl` or a lone - * `sync` beside it keeps its own message. A remote url (`libsql://`, - * `https://`, `ws(s)://`) is not judged here: there the storage is the - * server's. - * - * ⚠️ The text is `@objectstack/spec`'s `TursoConfigSchema` refusal on - * `encryptionKey` in its remote arm, byte for byte, held equal by - * `spec/turso-config-constructor-parity.test.ts`. Edit it there first. - */ -function remoteModeLocalEncryptionKeyRefusal(url: string): string { - if (namesInMemoryDatabase(url)) { - return ( - "`encryptionKey` asks for this datasource's database to be encrypted, but `url` names an in-memory " + - "database, and `mode: 'remote'` runs every read and write of it through the libSQL client this " + - 'driver builds, which is handed no key: there is no file for the key to encrypt, so it would change ' + - 'nothing — the turso driver refuses this configuration when it starts. Drop `encryptionKey`.' - ); - } - return ( - "`encryptionKey` asks for this datasource's database file to be encrypted, but `url` is a local " + - "`file:` url, and `mode: 'remote'` runs every read and write of that file through the libSQL client " + - 'this driver builds, which is handed no key: nothing would encrypt the file, so it would be written ' + - 'in plaintext that any SQLite reader opens without the key — the turso driver refuses this ' + - 'configuration when it starts. Nothing in this driver encrypts a local file. Drop `encryptionKey`, ' + - 'and encrypt the disk or volume the file lives on instead, or point `url` at a remote Turso ' + - "database, whose storage encryption is the server's to configure, not this key's." - ); -} - -/** - * Throw one of the four sync-key refusals above, or the local-engine - * `encryptionKey` refusal ({@link localEncryptionKeyRefusal}), as the + * Throw one of the four sync-key refusals above, or the retired + * `encryptionKey` refusal ({@link ENCRYPTION_KEY_RETIRED_REFUSAL}), as the * ADR-0112 envelope. * - * Raised BEFORE `super()`, AFTER the three refusals above it in the - * constructor, so no configuration those already refuse changes which message - * it gets. ⛔ The messages carry no url and no internal issue id: they reach an - * operator's boot log and Studio's datasource form. + * The sync-key refusals are raised BEFORE `super()`, AFTER the three + * refusals above them in the constructor, so no configuration those already + * refuse changes which message it gets; the `encryptionKey` one is raised + * first of all (see its constant). ⛔ The messages carry no url and no + * internal issue id: they reach an operator's boot log and Studio's datasource + * form. */ function refuseIgnoredSyncKey(message: string): never { const err = new Error(message) as Error & { code?: string; status?: number }; @@ -1640,15 +1581,6 @@ function namesInMemoryDatabase(url: string): boolean { return path === ':memory:' || path.startsWith(':memory:?'); } -/** - * [#25020] A url naming a database that lives on THIS host — a `file:` url - * (any letter case) or `:memory:` — whichever engine opens it. The - * `encryptionKey` refusal is judged by this, not by the transport mode. - */ -function namesLocalDatabase(url: string): boolean { - return url === ':memory:' || isFileUrl(url); -} - type LocalEngineDefect = 'remote-url' | 'unrecognised-url' | 'in-memory-replica'; /** @@ -2033,6 +1965,13 @@ export class TursoDriver extends SqlDriver { private remoteSequencesTableEnsured: Promise | null = null; constructor(config: TursoDriverConfig) { + // [#25050] The REMOVED `encryptionKey`, refused in every mode and FIRST, + // as the spec contract refuses it at the key ahead of every transport + // refusal. Typed `never`, so only an untyped value (a stored datasource row + // through the loaders) reaches here. See `ENCRYPTION_KEY_RETIRED_REFUSAL`. + if ((config as { encryptionKey?: unknown }).encryptionKey !== undefined) { + refuseIgnoredSyncKey(ENCRYPTION_KEY_RETIRED_REFUSAL); + } const mode = TursoDriver.detectMode(config); // A local or replica engine with nothing durable behind it (a remote url, // a url that is none of `file:` / `:memory:` / remote, or a replica on an @@ -2089,20 +2028,6 @@ export class TursoDriver extends SqlDriver { if (mode === 'local' && config.syncUrl) { refuseIgnoredSyncKey(LOCAL_MODE_WITH_SYNC_URL_REFUSAL); } - // [#25020] A key nothing this driver builds can honour, judged by WHERE - // THE DATABASE LIVES, not by the mode: a `file:` url or `:memory:`. - // In a local or replica mode better-sqlite3 has no encryption codec; under - // a forced `mode: 'remote'` the libSQL client `createRemoteClient()` builds - // for that url is handed no key. Either way the file would be written in - // plaintext. Refused before `super()`, in the spec's words, last. (In a - // local or replica mode every url that reaches here is a `file:` url or - // `:memory:`: `localEngineDefect` refused the rest above.) See - // `localEncryptionKeyRefusal` and `remoteModeLocalEncryptionKeyRefusal`. - if (config.encryptionKey && namesLocalDatabase(config.url)) { - refuseIgnoredSyncKey( - mode === 'remote' ? remoteModeLocalEncryptionKeyRefusal(config.url) : localEncryptionKeyRefusal(mode), - ); - } const knexConfig = TursoDriver.toKnexConfig(config, mode); super(knexConfig); this.tursoConfig = config; @@ -2411,10 +2336,10 @@ export class TursoDriver extends SqlDriver { // binding's BUSY timeout for local lock contention, not the remote // operation timeout `TursoDriverConfig.timeout` promises. That promise // is kept on `sync()`, the one remote operation this arm performs. - // [#25020] And no `encryptionKey`: a non-empty one is refused at - // construction, because this client shares the replica file with the - // Knex / better-sqlite3 engine, which cannot encrypt it, and a file - // this client encrypted is one that engine cannot open. + // [#25050] And no `encryptionKey`: the key is removed and refused at + // construction. This client shares the replica file with the Knex / + // better-sqlite3 engine, which cannot encrypt it, and a file this + // client encrypted is one that engine cannot open. this.libsqlClient = createClient({ url: this.tursoConfig.url, authToken: this.tursoConfig.authToken, diff --git a/packages/mcp/src/stdio-data-bridge.stored-metadata-body.test.ts b/packages/mcp/src/stdio-data-bridge.stored-metadata-body.test.ts index 16fa55990a5..a07fb6b9cd1 100644 --- a/packages/mcp/src/stdio-data-bridge.stored-metadata-body.test.ts +++ b/packages/mcp/src/stdio-data-bridge.stored-metadata-body.test.ts @@ -78,7 +78,7 @@ const BODIES: Record }> = }, ds_turso: { type: 'datasource', - body: { name: 'ds_turso', driver: 'turso', config: { url: 'libsql://db.example.invalid', encryptionKey: TURSO_CRED } }, + body: { name: 'ds_turso', driver: 'turso', config: { url: 'libsql://db.example.invalid', authToken: TURSO_CRED } }, }, extra: { type: EXTRA_TYPE, body: { name: 'extra', label: 'Extra', token: EXTRA_CRED } }, // Controls: a redactor-bearing type with nothing to withhold, and a type with no redactor. diff --git a/packages/metadata-protocol/src/protocol.data-door-stored-content-hash.test.ts b/packages/metadata-protocol/src/protocol.data-door-stored-content-hash.test.ts index 1559095ee71..62439761cfa 100644 --- a/packages/metadata-protocol/src/protocol.data-door-stored-content-hash.test.ts +++ b/packages/metadata-protocol/src/protocol.data-door-stored-content-hash.test.ts @@ -40,7 +40,7 @@ const dsBody = (cred: string) => ({ name: 'edge_cache', label: 'Edge Cache', driver: 'turso', - config: { url: 'file:/var/data/edge.db', encryptionKey: cred }, + config: { url: 'file:/var/data/edge.db', authToken: cred }, }); const viewBody = { name: 'all_tasks', label: 'All Tasks', type: 'grid' }; diff --git a/packages/metadata-protocol/src/protocol.data-door-stored-metadata-redaction.test.ts b/packages/metadata-protocol/src/protocol.data-door-stored-metadata-redaction.test.ts index 7131862f78a..5d6a6adb63e 100644 --- a/packages/metadata-protocol/src/protocol.data-door-stored-metadata-redaction.test.ts +++ b/packages/metadata-protocol/src/protocol.data-door-stored-metadata-redaction.test.ts @@ -36,7 +36,7 @@ const SECRETS = [ 'stored-nested-token', ] as const; -/** A legacy turso row: the still-writable key plus an alias spelling. */ +/** A legacy turso row: the RETIRED `encryptionKey` (#25050), which a stored row may still carry, plus an alias spelling. */ const TURSO_BODY = { name: 'edge_cache', label: 'Edge Cache', diff --git a/packages/metadata-protocol/src/stored-metadata-body-family.pin.test.ts b/packages/metadata-protocol/src/stored-metadata-body-family.pin.test.ts index 3a7954f8d29..be5b7cedaa4 100644 --- a/packages/metadata-protocol/src/stored-metadata-body-family.pin.test.ts +++ b/packages/metadata-protocol/src/stored-metadata-body-family.pin.test.ts @@ -186,7 +186,7 @@ const storedDatasourceRow = (object = 'sys_metadata') => ({ [STORED_METADATA_BODY_COLUMN]: JSON.stringify({ name: 'ds', driver: 'turso', - config: { url: 'libsql://db.turso.io', encryptionKey: CREDENTIAL }, + config: { url: 'libsql://db.turso.io', authToken: CREDENTIAL }, }), __object: object, }); diff --git a/packages/objectql/src/engine-formula-hop-stored-metadata-body.test.ts b/packages/objectql/src/engine-formula-hop-stored-metadata-body.test.ts index bea77e42761..35c38eda55b 100644 --- a/packages/objectql/src/engine-formula-hop-stored-metadata-body.test.ts +++ b/packages/objectql/src/engine-formula-hop-stored-metadata-body.test.ts @@ -108,7 +108,7 @@ function makeDriver() { /** A synthetic stored datasource body with placeholder credential material. */ const SECRET = 'PLACEHOLDER-SECRET'; -const STORED_BODY = JSON.stringify({ name: 'ds', driver: 'turso', config: { url: 'libsql://x.invalid', encryptionKey: SECRET } }); +const STORED_BODY = JSON.stringify({ name: 'ds', driver: 'turso', config: { url: 'libsql://x.invalid', authToken: SECRET } }); const STORED_HASH = 'sha256:PLACEHOLDER-HASH'; const MEMBER = { userId: 'u1', tenantId: 'org_1', positions: ['member'] } as any; const SYSTEM = { isSystem: true } as any; diff --git a/packages/objectql/src/engine-hydration-stored-metadata-body.test.ts b/packages/objectql/src/engine-hydration-stored-metadata-body.test.ts index 19097570ac2..c93eb73dbe9 100644 --- a/packages/objectql/src/engine-hydration-stored-metadata-body.test.ts +++ b/packages/objectql/src/engine-hydration-stored-metadata-body.test.ts @@ -101,7 +101,7 @@ function makeDriver() { /** A synthetic stored datasource body with placeholder credential material, and a control body without it. */ const SECRET = 'PLACEHOLDER-SECRET'; -const STORED_BODY = JSON.stringify({ name: 'ds', driver: 'turso', config: { url: 'libsql://x.invalid', encryptionKey: SECRET } }); +const STORED_BODY = JSON.stringify({ name: 'ds', driver: 'turso', config: { url: 'libsql://x.invalid', authToken: SECRET } }); const CONTROL_BODY = JSON.stringify({ name: 'ds2', driver: 'turso', config: { url: 'libsql://y.invalid' } }); const MEMBER = { userId: 'u1', tenantId: 'org_1', positions: ['member'] } as any; const SYSTEM = { isSystem: true } as any; diff --git a/packages/objectql/src/engine-realtime-stored-metadata-body.test.ts b/packages/objectql/src/engine-realtime-stored-metadata-body.test.ts index 33abace0cb4..39361e9da6d 100644 --- a/packages/objectql/src/engine-realtime-stored-metadata-body.test.ts +++ b/packages/objectql/src/engine-realtime-stored-metadata-body.test.ts @@ -15,7 +15,7 @@ import { ObjectQL } from './engine.js'; const CRED = 'event-cred-a91f'; const datasourceBody = (cred = CRED) => - JSON.stringify({ name: 'ds', driver: 'turso', config: { url: 'libsql://db.turso.io', encryptionKey: cred } }); + JSON.stringify({ name: 'ds', driver: 'turso', config: { url: 'libsql://db.turso.io', authToken: cred } }); const sysMetadata = { name: 'sys_metadata', @@ -112,7 +112,7 @@ describe('[#21120] realtime data.record.* events redact a stored metadata body', // The event still describes the row — type survives, the body is still present but projected. expect(payload.after.type).toBe('datasource'); expect(payload.after.metadata).toBeDefined(); - expect(JSON.parse(payload.after.metadata).config.encryptionKey).toBeUndefined(); + expect(JSON.parse(payload.after.metadata).config.authToken).toBeUndefined(); expect(JSON.parse(payload.after.metadata).config.url).toBe('libsql://db.turso.io'); }); diff --git a/packages/plugins/plugin-audit/src/audit-writers.test.ts b/packages/plugins/plugin-audit/src/audit-writers.test.ts index d1c8ad5e099..4f65170bd86 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.test.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.test.ts @@ -2431,7 +2431,7 @@ describe('audit writers — the writer reads the session key the engine emits (# describe('[#21120] stored metadata body copies are redacted at write time', () => { const CRED = 'writer-cred-5d02'; const dsBody = (cred = CRED) => - JSON.stringify({ name: 'ds', driver: 'turso', config: { url: 'libsql://db.turso.io', encryptionKey: cred } }); + JSON.stringify({ name: 'ds', driver: 'turso', config: { url: 'libsql://db.turso.io', authToken: cred } }); // sys_metadata is a stored-metadata-body table: a `type` + `metadata` row. const SCHEMAS = { @@ -2456,7 +2456,7 @@ describe('[#21120] stored metadata body copies are redacted at write time', () = // The audit copy still records the change: type survives, the projected body is present. const newValue = JSON.parse(audit!.row.new_value); expect(newValue.type).toBe('datasource'); - expect(JSON.parse(newValue.metadata).config.encryptionKey).toBeUndefined(); + expect(JSON.parse(newValue.metadata).config.authToken).toBeUndefined(); expect(JSON.parse(newValue.metadata).config.url).toBe('libsql://db.turso.io'); expect(JSON.stringify(activity!.row)).not.toContain(CRED); }); diff --git a/packages/plugins/plugin-audit/src/stored-metadata-body-migration.test.ts b/packages/plugins/plugin-audit/src/stored-metadata-body-migration.test.ts index deb36c82592..df5e3df6155 100644 --- a/packages/plugins/plugin-audit/src/stored-metadata-body-migration.test.ts +++ b/packages/plugins/plugin-audit/src/stored-metadata-body-migration.test.ts @@ -26,7 +26,7 @@ const CRED = 'at-rest-cred-51be'; /** The serialized metadata body as the audit copy holds it (a datasource body). */ const body = (cred = CRED) => - JSON.stringify({ name: 'ds', driver: 'turso', config: { url: 'libsql://db.turso.io', encryptionKey: cred } }); + JSON.stringify({ name: 'ds', driver: 'turso', config: { url: 'libsql://db.turso.io', authToken: cred } }); /** A create-snapshot ledger view: the whole sys_metadata row, type + metadata present. */ const createSnapshot = () => ({ id: 'm1', name: 'ds', type: 'datasource', scope: 'platform', metadata: body() }); diff --git a/packages/runtime/src/stored-metadata-body-reads.test.ts b/packages/runtime/src/stored-metadata-body-reads.test.ts index 578ed136ecc..f7a2af831e9 100644 --- a/packages/runtime/src/stored-metadata-body-reads.test.ts +++ b/packages/runtime/src/stored-metadata-body-reads.test.ts @@ -52,7 +52,7 @@ function storedRow(): Record { id: 'row_1', type: 'datasource', name: 'unit_ds', - metadata: JSON.stringify({ name: 'unit_ds', driver: 'turso', config: { url: 'libsql://unit.example.invalid', encryptionKey: SENTINEL } }), + metadata: JSON.stringify({ name: 'unit_ds', driver: 'turso', config: { url: 'libsql://unit.example.invalid', authToken: SENTINEL } }), checksum: `sha256:${'b2'.repeat(32)}`, }; } diff --git a/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts b/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts index 08eae23cad7..4a5c876c5ca 100644 --- a/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts +++ b/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts @@ -85,7 +85,13 @@ const API = '/api/v1'; const ADMIN = { email: 'admin@objectos.ai', password: 'admin123' }; const MEMBER = { email: 'pin-member@example.invalid', password: 'Member-Pass-123' }; -/** The synthetic credential, stored in the datasource body's still-writable credential slot. */ +/** + * The synthetic credential, stored in a credential slot the production writer + * ACCEPTS and the datasource redactor withholds on read: mongodb's + * `options.proxyPassword`. It was turso's `encryptionKey` until #25050 retired + * that key, and the primary slots (`password`, `authToken`) are refused inline + * at this writer, so they cannot seed the row. + */ const SENTINEL = 'reader-seam-sentinel-6c1e9b'; const DS_NAME = 'pin_ds'; /** Non-credential configuration of the same body: present in a projection of THIS row. */ @@ -433,8 +439,8 @@ beforeAll(async () => { const save = await as(adminToken, 'PUT', `/meta/datasource/${DS_NAME}`, { name: DS_NAME, label: 'Pin datasource', - driver: 'turso', - config: { url: `libsql://${DS_HOST}`, encryptionKey: SENTINEL }, + driver: 'mongodb', + config: { url: `mongodb://${DS_HOST}:27017/pin`, options: { proxyPassword: SENTINEL } }, }); if (save.status >= 300) throw new Error(`pin seed refused: ${save.status} ${JSON.stringify(await readJson(save))}`); diff --git a/packages/runtime/src/stored-metadata-reader-seam.test.ts b/packages/runtime/src/stored-metadata-reader-seam.test.ts index b8c5b499d63..159cb4ade5c 100644 --- a/packages/runtime/src/stored-metadata-reader-seam.test.ts +++ b/packages/runtime/src/stored-metadata-reader-seam.test.ts @@ -32,7 +32,7 @@ function storedRow(): Record { metadata: JSON.stringify({ name: 'unit_ds', driver: 'turso', - config: { url: 'libsql://unit.example.invalid', encryptionKey: SENTINEL }, + config: { url: 'libsql://unit.example.invalid', authToken: SENTINEL }, }), checksum: STORED_HASH, }; diff --git a/packages/runtime/src/turso-driver-factory.convergence.test.ts b/packages/runtime/src/turso-driver-factory.convergence.test.ts index 94bf86a9042..dfd6dd29aa9 100644 --- a/packages/runtime/src/turso-driver-factory.convergence.test.ts +++ b/packages/runtime/src/turso-driver-factory.convergence.test.ts @@ -60,7 +60,6 @@ const FULL_SPEC: DatasourceConnectionSpec = { config: { url: 'libsql://my-db.turso.io', authToken: 'jwt-token', - encryptionKey: 'aes-256-key', concurrency: 7, syncUrl: 'libsql://replica.turso.io', sync: { intervalSeconds: 30, onConnect: false }, @@ -94,7 +93,6 @@ describe('#7314 point 3 — the host loader reads the whole libSQL config, not t expect(seen[0]).toEqual({ url: 'libsql://my-db.turso.io', authToken: 'jwt-token', - encryptionKey: 'aes-256-key', concurrency: 7, syncUrl: 'libsql://replica.turso.io', sync: { intervalSeconds: 30, onConnect: false }, @@ -114,7 +112,11 @@ describe('#7314 point 3 — the host loader reads the whole libSQL config, not t factory.create(FULL_SPEC); expect(seen[0]).toEqual(buildTursoDriverConfig(FULL_SPEC, 'libsql://my-db.turso.io')); - expect(Object.keys(seen[0] as object).sort()).toEqual([...TURSO_DRIVER_CONFIG_KEYS].sort()); + // `encryptionKey` is the one reader no datasource can satisfy: the key is + // retired (#25050), and its reader exists only to forward a legacy stored + // value to the driver constructor's refusal. Every other key is declared. + expect(Object.keys(seen[0] as object).sort()) + .toEqual(TURSO_DRIVER_CONFIG_KEYS.filter((k) => k !== 'encryptionKey').sort()); }); // Absent ≠ present-and-undefined: `@libsql/client` reads some options by diff --git a/packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts b/packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts index 35263bca12b..4295cfc885f 100644 --- a/packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts +++ b/packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts @@ -53,12 +53,12 @@ import { installDefaultOrganization } from '../default-organization.testkit.js'; const SYS = { isSystem: true } as const; const KEYED = /^hmac-sha256:[0-9a-f]{64}$/; -/** The synthetic credential, in the slot the datasource redactor withholds (turso's `encryptionKey`). */ +/** The synthetic credential, in the slot the datasource redactor withholds (turso's `authToken`). */ const SENTINEL = 'flow-read-family-sentinel-4d2b'; const DS_NAME = 'pin_flow_ds'; /** Non-credential configuration of the same body: present in a projection of THIS row. */ const DS_URL = 'libsql://pin-flow.example.invalid'; -const DS_BODY = { name: DS_NAME, label: 'Pin flow DS', driver: 'turso', config: { url: DS_URL, encryptionKey: SENTINEL } }; +const DS_BODY = { name: DS_NAME, label: 'Pin flow DS', driver: 'turso', config: { url: DS_URL, authToken: SENTINEL } }; const STORED_HASH = hashSpec(DS_BODY); /** An ordinary object a flow copies what it read into. */ diff --git a/packages/services/service-automation/src/builtin/get-record-stored-metadata-filter-refusal.integration.test.ts b/packages/services/service-automation/src/builtin/get-record-stored-metadata-filter-refusal.integration.test.ts index 5a439f7b29e..b6907448dd8 100644 --- a/packages/services/service-automation/src/builtin/get-record-stored-metadata-filter-refusal.integration.test.ts +++ b/packages/services/service-automation/src/builtin/get-record-stored-metadata-filter-refusal.integration.test.ts @@ -54,11 +54,11 @@ const SYS = { isSystem: true } as const; const KEYED = /^hmac-sha256:[0-9a-f]{64}$/; const FAMILY = ['sys_metadata', 'sys_metadata_history'] as const; -/** The synthetic credential, in the slot the datasource redactor withholds (turso's `encryptionKey`). */ +/** The synthetic credential, in the slot the datasource redactor withholds (turso's `authToken`). */ const SENTINEL = 'flow-filter-refusal-sentinel-91f3'; const DS_NAME = 'pin_filter_ds'; const DS_URL = 'libsql://pin-filter.example.invalid'; -const DS_BODY = { name: DS_NAME, label: 'Pin filter DS', driver: 'turso', config: { url: DS_URL, encryptionKey: SENTINEL } }; +const DS_BODY = { name: DS_NAME, label: 'Pin filter DS', driver: 'turso', config: { url: DS_URL, authToken: SENTINEL } }; const STORED_HASH = hashSpec(DS_BODY); /** A well-formed hash that is not the stored one. */ const OTHER_HASH = hashSpec({ ...DS_BODY, label: 'another body' }); diff --git a/packages/services/service-datasource/src/__tests__/datasource-config-redaction.test.ts b/packages/services/service-datasource/src/__tests__/datasource-config-redaction.test.ts index bcdf1226c84..644eb4da595 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-config-redaction.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-config-redaction.test.ts @@ -152,14 +152,18 @@ describe('read path: getDatasource() no longer serves a stored credential', () = expect(ds!.config).toEqual({ host: 'h', database: 'd' }); }); - it('turso: the still-writable `encryptionKey` is redacted on read as well', async () => { + // #25050 — `encryptionKey` is RETIRED: no new row can carry it, but a legacy + // stored row still may, and a retired key is credential-shaped all the same. + // A local url, because that is the row the load-path conversion leaves for + // its author (beside a remote url it strips the key before this read). + it('turso: a legacy stored row\'s RETIRED `encryptionKey` is still redacted on read', async () => { const { service } = makeService([{ name: 'turso_ds', driver: 'turso', origin: 'runtime', - config: { url: 'libsql://db.turso.io', authToken: 'jwt-token', encryptionKey: 'aes-256-key' }, + config: { url: 'file:/var/data/edge.db', encryptionKey: 'aes-256-key' }, }]); const ds = await service.getDatasource('turso_ds'); - expect(ds!.config).toEqual({ url: 'libsql://db.turso.io' }); - expect(ds!.redactedConfigKeys).toEqual(['authToken', 'encryptionKey']); + expect(ds!.config).toEqual({ url: 'file:/var/data/edge.db' }); + expect(ds!.redactedConfigKeys).toEqual(['encryptionKey']); }); it('pre-#8078 ALIAS spellings are redacted — a stored row never met the parse that renamed them', async () => { @@ -633,7 +637,7 @@ describe('every accepted spelling of a builtin driver, at the service door', () config: { url: 'file:./fixture.db', [key]: 'fixture-secret' }, }); - it('getDatasource() withholds the still-writable credential key under each accepted spelling, and names it', async () => { + it('getDatasource() withholds the retired credential key under each accepted spelling, and names it', async () => { // Population floor: the class is not empty today, so a derivation that // silently found nothing fails here instead of passing over zero rows. expect(CASES.length).toBeGreaterThan(0); diff --git a/packages/services/service-datasource/src/__tests__/datasource-credential-migration.test.ts b/packages/services/service-datasource/src/__tests__/datasource-credential-migration.test.ts index e673d5f2e94..9f2f799f011 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-credential-migration.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-credential-migration.test.ts @@ -132,7 +132,7 @@ describe('planCredentialMigration — stated outcomes for what it will not touch expect(plan.reason).toContain('config.passwd'); }); - it('refuses turso\'s `encryptionKey` — credential-shaped, but not the binder\'s slot', () => { + it('refuses turso\'s RETIRED `encryptionKey` — credential-shaped, but not the binder\'s slot', () => { const plan = refusal( row({ driver: 'turso', config: { url: 'libsql://x.turso.io', encryptionKey: 'aes-key' } }), ); @@ -313,7 +313,7 @@ describe('planCredentialMigration reads the same list under every accepted spell variantsOf(spelling).flatMap((variant) => stillWritableOf(id).map((key) => ({ variant, id, key }))), ); - it('a bindable row names the still-writable credential key as its residue, byte-equal to the canonical spelling', () => { + it('a bindable row names the retired credential key as its residue, byte-equal to the canonical spelling', () => { // Population floor: the class is not empty today. expect(CASES.length).toBeGreaterThan(0); for (const { variant, id, key } of CASES) { @@ -326,7 +326,7 @@ describe('planCredentialMigration reads the same list under every accepted spell } }); - it('a row holding only the still-writable credential key is refused with that key named, as under the canonical spelling', () => { + it('a row holding only the retired credential key is refused with that key named, as under the canonical spelling', () => { for (const { variant, id, key } of CASES) { const config = { url: 'file:./fixture.db', [key]: 'fixture-secret' }; const canonical = planCredentialMigration(row({ driver: id, config })); 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 index 9d69beebcef..282a762c2c1 100644 --- 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 @@ -36,8 +36,8 @@ * 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 + * - "binds the secret to authToken alone, never to the retired encryptionKey" + * — 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). * @@ -207,16 +207,16 @@ describe('#8152 — the credential route a NEW turso datasource has left', () => 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. + // ⛔ Step 1 is one slot: the primary credential. The question of a second + // slot for `encryptionKey` is closed: #25050 retired the key (it never + // encrypted anything) and the driver constructor refuses it in every mode, so + // binding the secret there would hand the driver a key it refuses. 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 () => { + it('RED ON MAIN — binds the secret to authToken alone, never to the retired encryptionKey', async () => { const { seen } = await authorThenConnect(THE_BOUND_JWT); const config = buildTursoDriverConfig(seen[0]!, resolveTursoUrl(seen[0]!)); expect(config.authToken).toBe(THE_BOUND_JWT); 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 bdf742ae916..290574703a4 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 @@ -41,7 +41,6 @@ describe('buildTursoDriverConfig (#7314)', () => { config: { url: 'libsql://my-db.turso.io', authToken: 'jwt-token', - encryptionKey: 'aes-256-key', concurrency: 7, syncUrl: 'libsql://replica.turso.io', sync: { intervalSeconds: 30, onConnect: false }, @@ -52,7 +51,6 @@ describe('buildTursoDriverConfig (#7314)', () => { expect(buildTursoDriverConfig(spec, resolveTursoUrl(spec))).toEqual({ url: 'libsql://my-db.turso.io', authToken: 'jwt-token', - encryptionKey: 'aes-256-key', concurrency: 7, syncUrl: 'libsql://replica.turso.io', sync: { intervalSeconds: 30, onConnect: false }, @@ -72,6 +70,9 @@ describe('buildTursoDriverConfig (#7314)', () => { config: { url: 'libsql://x', authToken: 'a', + // Retired (#25050) and refused by the schema, but still READ: a legacy + // stored row that carries it must reach the driver constructor's + // refusal rather than be dropped in silence. See the pin below. encryptionKey: 'k', concurrency: 1, syncUrl: 'libsql://y', @@ -98,12 +99,30 @@ describe('buildTursoDriverConfig (#7314)', () => { it('treats empty string credentials as unset and keeps zero-valued numbers', () => { const spec: DatasourceConnectionSpec = { driver: 'turso', - config: { url: 'libsql://x', authToken: '', encryptionKey: '', syncUrl: '', concurrency: 0, timeoutMs: 0 }, + config: { url: 'libsql://x', authToken: '', syncUrl: '', concurrency: 0, timeoutMs: 0 }, }; expect(buildTursoDriverConfig(spec, resolveTursoUrl(spec))) .toEqual({ url: 'libsql://x', concurrency: 0, timeout: 0 }); }); + // #25050 — `encryptionKey` is retired: the schema refuses it and the driver + // constructor refuses it in every mode. The builder is the seam between the + // two for a row that never crossed the schema (a legacy stored row), so it + // forwards ANY declared value — the empty string included, which is no + // longer "unset" but a key the author must delete — and lets the + // constructor refuse it by name. Dropping it here would turn a loud refusal + // back into the silent no-op the key always was. + it('forwards a declared retired encryptionKey, whatever its value, to the constructor', () => { + for (const value of ['aes-256-key', '', 0, null]) { + const spec: DatasourceConnectionSpec = { + driver: 'turso', + config: { url: 'file:./a.db', encryptionKey: value }, + }; + expect(buildTursoDriverConfig(spec, resolveTursoUrl(spec))) + .toEqual({ url: 'file:./a.db', encryptionKey: value }); + } + }); + // #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 @@ -168,15 +187,13 @@ describe('buildTursoDriverConfig (#7314)', () => { }); // ⛔ #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. + // secret must NOT be sprayed into `encryptionKey`. The question #8152 left + // open is now closed the other way: #25050 retired the key (it never + // encrypted anything), so there is no second slot to bind, and forwarding + // the secret there would hand the constructor a key it refuses. // // 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. + // assertion failed). it('RED ON MAIN — binds the secret to authToken ONLY, never to encryptionKey', () => { const spec: DatasourceConnectionSpec = { driver: 'turso', @@ -308,6 +325,15 @@ describe('#16023 — every reader consults the spelling the authoring contract a // arrives (`loadDatasourceRows` / `loadDatasourceRow`), so a `??` fallback // here would be a consumer-side dialect for a spelling both doors closed — // the sqlite `filename` and mongo `url` arms say exactly this. + // + // ONE named exception, and it is a refusal, not a tolerance: `encryptionKey` + // (#25050) is a removal with no successor whose conversion deliberately + // leaves a non-empty value beside a LOCAL url for its author. Its reader + // forwards that value verbatim, under its own name, so the driver + // constructor refuses it by name instead of the row booting in silence. It + // is held to exactly that below: forwarded unchanged, never read into any + // other key. + const FORWARDED_TO_CONSTRUCTOR_REFUSAL = new Set(['encryptionKey']); it('reads NO retired spelling — a config authored the old way yields only `url`', () => { for (const { retired } of retirements()) { // Probed at every candidate type, not at the replacement's: a tolerance @@ -315,12 +341,20 @@ describe('#16023 — every reader consults the spelling the authoring contract a // itself accepts nothing to sample from. for (const value of CANDIDATES) { const built = build({ url: 'libsql://x', [retired]: value }); + if (FORWARDED_TO_CONSTRUCTOR_REFUSAL.has(retired)) { + expect(built, `\`${retired}\` is forwarded unchanged (probed with ${typeof value})`) + .toEqual({ url: 'libsql://x', [retired]: value }); + continue; + } expect( Object.keys(built), `a reader still consults the retired \`${retired}\` (probed with ${typeof value})`, ).toEqual(['url']); } } + // The exception list names only keys the contract really retired. + const retiredNames = retirements().map((r) => r.retired); + for (const key of FORWARDED_TO_CONSTRUCTOR_REFUSAL) expect(retiredNames).toContain(key); }); // The named, readable instance of the property above — the case #16023 diff --git a/packages/services/service-datasource/src/datasource-credential-migration.ts b/packages/services/service-datasource/src/datasource-credential-migration.ts index bacf45e61c1..3716f50303d 100644 --- a/packages/services/service-datasource/src/datasource-credential-migration.ts +++ b/packages/services/service-datasource/src/datasource-credential-migration.ts @@ -42,11 +42,11 @@ * that authenticates without it today, which is a behaviour change on a * working datasource. They are reported as `remaining` instead, so the * operator can clear them by hand. - * - **`encryptionKey` (turso)** — credential-shaped and deliberately still - * writable (#8078). The binder injects exactly ONE secret slot and that slot - * is the `authToken`; binding an encryption key into it would hand the - * driver the wrong credential. Giving it a slot of its own is #8081 item 4 - * and is NOT decided here. + * - **`encryptionKey` (turso)** — credential-shaped and RETIRED (#25050): it + * was honoured in no mode, and no door accepts it any more. The binder + * injects exactly ONE secret slot and that slot is the `authToken`; binding + * a retired key into it would hand the driver the wrong credential. The + * remedy is deleting it. * - **A credential embedded in a URL's userinfo** — see below. * * ## Why a URL-embedded credential is REFUSED rather than extracted @@ -130,14 +130,14 @@ export function urlCredentialKeys(config: Record | undefined): /** * Credential-shaped config keys this action will not bind: the pre-#8078 alias - * spellings and any driver key that is credential-shaped but still writable + * spellings and any retired driver key whose stored value is still redacted * (turso's `encryptionKey`). * * Derived by subtracting the driver's bindable slot from the READ path's own * redaction set (`redactableConfigKeys`, the one security list #8300 put in * spec) — never a second hand-written list. A refused key added to a driver * contract tomorrow therefore lands on the bindable side automatically, and a - * new still-writable credential key lands here. + * newly retired credential key lands here. */ function unbindableCredentialKeys( driver: unknown, @@ -267,8 +267,8 @@ export function planCredentialMigration(record: StoredDatasource): CredentialMig reason: `Datasource '${record.name}' holds ${named}, which the ${String(record.driver)} driver does not ` + 'read as its credential — an alias spelling from before inline credentials were refused at ' - + 'publish, which no connection builder reads, or a key that is credential-shaped but ' - + 'deliberately still writable (turso\'s `encryptionKey`, which has no secret slot of its own: ' + + 'publish, which no connection builder reads, or a retired key that is credential-shaped ' + + '(turso\'s `encryptionKey`, which never encrypted anything and is no longer accepted: ' + 'the one slot carries the `authToken`). Binding one into the ' + 'single secret slot would hand the driver a credential it does not use, or add authentication ' + 'to a connection that works without it today.', diff --git a/packages/services/service-datasource/src/turso-driver-config.ts b/packages/services/service-datasource/src/turso-driver-config.ts index a23c80cddf6..665e9aa3dfa 100644 --- a/packages/services/service-datasource/src/turso-driver-config.ts +++ b/packages/services/service-datasource/src/turso-driver-config.ts @@ -19,7 +19,7 @@ * * Each read the spec's `config` with its own hand-written list, and the lists * had drifted: the open-core arm read nine keys, the host loader read `url` and - * `authToken`. So an encrypted or embedded-replica `default` silently lost + * `authToken`. So an embedded-replica `default` silently lost * `encryptionKey` / `syncUrl` / `sync` / `concurrency` / `timeout` / `mode` — * `TursoConfigSchema` accepts all of them, so this was declared-but-not-enforced * in one of the two positions, with no diagnostic anywhere. The same @@ -64,13 +64,12 @@ export interface TursoDriverConfigInput { /** JWT auth token for a remote Turso database. */ authToken?: string; /** - * Passed through as authored, and honoured by the driver in no mode: it - * refuses a non-empty key beside a `file:` url or `:memory:` in every mode - * (`VALIDATION_ERROR` / 400), because nothing it builds encrypts a local - * file, and does not forward it to a remote database, whose storage - * encryption is the server's. + * REMOVED (#25050) — typed `never` exactly as the driver's own slot is, so + * nothing typed can set it. The builder still forwards a value a stored row + * carries, so that `new TursoDriver()` refuses it loudly in every mode + * instead of this table dropping it in silence; see the reader. */ - encryptionKey?: string; + encryptionKey?: never; /** Max concurrent requests to the remote database (replica/remote modes). */ concurrency?: number; /** Remote sync url for embedded-replica mode. */ @@ -117,8 +116,8 @@ interface TursoConfigSource { * this was lifted from had always spread-omitted. * * 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 + * string keys (an empty `authToken` / `syncUrl` is an unset one, never a + * credential of length zero) and its absence on the number keys * (`concurrency: 0` and `timeoutMs: 0` are meaningful values the driver reads). * * A reader is not obliged to read only `config`: `schemaMode` and `authToken` @@ -169,8 +168,25 @@ const TURSO_CONFIG_READERS: { : typeof config.authToken === 'string' && config.authToken ? config.authToken : undefined, + /** + * The REMOVED key (#25050), forwarded as stored — never honoured, only + * refused. + * + * No door accepts it any more (`retiredKey()` on the spec contract), and a + * stored row replays the D2 conversion `turso-config-encryption-key-removed` + * at `loadDatasourceRows` / `loadDatasourceRow` before it reaches this + * table, which strips the key wherever removing it changes nothing (an + * unset value; a remote url, where the driver never handed it on). What can + * still arrive is a non-empty key beside a local url, which the conversion + * leaves for the author on purpose: that operator believed the file + * encrypted. Dropping it here would boot that datasource in plaintext in + * silence; forwarding it makes `new TursoDriver()` refuse it with the + * retirement's prescription, as it has refused it since #25020. Not a + * consumer-side dialect (Prime Directive #12): it reads nothing, it hands + * the value to the one door that refuses it. + */ encryptionKey: ({ config }) => - typeof config.encryptionKey === 'string' && config.encryptionKey ? config.encryptionKey : undefined, + config.encryptionKey !== undefined ? (config.encryptionKey as never) : undefined, concurrency: ({ config }) => (typeof config.concurrency === 'number' ? config.concurrency : undefined), syncUrl: ({ config }) => typeof config.syncUrl === 'string' && config.syncUrl ? config.syncUrl : undefined, diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index d8fb766a88b..c450d0337c4 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -992,7 +992,7 @@ "data/TenancyConfig:tenantField", "data/TursoConfig:authToken [RETIRED]", "data/TursoConfig:concurrency", - "data/TursoConfig:encryptionKey", + "data/TursoConfig:encryptionKey [RETIRED]", "data/TursoConfig:mode", "data/TursoConfig:sync", "data/TursoConfig:syncUrl", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index bbe91d21d55..8fbb73c721c 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,7 +3,7 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 240, - "droppedRefinementSites": 812, + "droppedRefinementSites": 811, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -998,7 +998,6 @@ "data/TursoConfig": { "sites": [ "", - "encryptionKey", "syncUrl", "url" ] diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 92255a1191a..6213f70784a 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -14295,6 +14295,114 @@ const translationWidgetSubCaptionRemoved: MetadataConversion = { }, }; +/** + * The remote-url prefixes the turso driver classifies as remote, matched in + * any letter case — the list `TURSO_REMOTE_URL_PREFIXES` in + * `data/driver/turso.zod.ts` carries (module-local there, and not worth a + * public export for one conversion). + */ +const TURSO_REMOTE_URL_PREFIXES = ['libsql://', 'https://', 'http://', 'wss://', 'ws://'] as const; + +/** + * `datasources[].config.encryptionKey` leaves the turso contract (protocol 18, + * #25050 — ADR-0049 enforce-or-remove). + * + * The key promised an AES-256 key for the local database file and was + * honoured in no mode: the driver reads and writes a local or replica file + * through better-sqlite3, which has no encryption codec, and it never handed + * the key to the libSQL client it builds for a remote url. #25020 refused it + * beside a `file:` url or `:memory:`; #25050 tombstones it (`retiredKey()` on + * `TursoConfigSchema`), so a live author is refused at parse in every mode. + * + * **Partial on purpose: it strips only where stripping changes nothing.** + * + * - an UNSET value — an empty string, or any non-string a parse never + * accepted — which every loader already dropped; + * - a non-empty key beside a REMOTE url (`libsql://`, `https://`, `http://`, + * `ws(s)://`, any letter case, trimmed as the loaders trim it), which the + * driver parsed and dropped: removing it leaves the connection exactly as + * it was. + * + * A non-empty key beside a local url (`file:` or `:memory:`, in any mode) is + * LEFT for the author, as #25020 left it: that datasource's operator believed + * the file encrypted, and whether to accept a plaintext file, move it to an + * encrypted volume or move the data to a remote database is intent no + * artifact records — the D3 entry `turso-config-encryption-key-retired` + * carries it. Stripping it here would turn #25020's loud refusal at driver + * construction back into silence for exactly the rows it exists to surface. + * + * Driver-aware exactly like {@link tursoConfigTimeoutToTimeoutMs}: an + * `encryptionKey` under another driver is not this contract's key and is never + * touched; `resolveDriverId` also catches a stored `driver: 'libsql'`. + * `@objectstack/driver-turso`'s mirror tombstones the same key and registers + * no second entry (the precedent the timeout rename set). + * + * Retired from the load path: stored rows (`loadDatasourceRows` / + * `loadDatasourceRow` replay the full chain), built artifacts and + * `os migrate meta --from 17` replay it. Idempotent: a stripped config has no + * key left to strip. + */ +const tursoConfigEncryptionKeyRemoved: MetadataConversion = { + id: 'turso-config-encryption-key-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '18.0.0', + surface: 'datasource.config.encryptionKey (turso)', + summary: + "turso datasource key 'config.encryptionKey' removed where it changed nothing — an unset value, or a " + + 'key beside a remote url, which the driver never handed to the libSQL client (ADR-0049 ' + + 'enforce-or-remove: the key was honoured in no mode); a non-empty key beside a local file: url or ' + + ':memory: is left for the author, since that operator believed the file encrypted', + apply(stack, emit) { + return mapDatasources(stack, (ds, path) => { + if (resolveDriverId(ds.driver) !== 'turso') return ds; + const config = ds.config; + if (!config || typeof config !== 'object' || Array.isArray(config)) return ds; + const cfg = config as Record; + if (!('encryptionKey' in cfg)) return ds; + const key = cfg.encryptionKey; + const unset = typeof key !== 'string' || key === ''; + const url = typeof cfg.url === 'string' ? cfg.url.trim() : ''; + const remote = TURSO_REMOTE_URL_PREFIXES.some( + (prefix) => url.slice(0, prefix.length).toLowerCase() === prefix, + ); + if (!unset && !remote) return ds; + const { encryptionKey: _removed, ...rest } = cfg; + emit({ from: 'encryptionKey', to: '(removed)', path: `${path}.config.encryptionKey` }); + return { ...ds, config: rest }; + }); + }, + fixture: { + before: { + datasources: [ + // A remote url: the driver dropped the key, so it goes. + { name: 'edge_db', driver: 'turso', config: { url: 'libsql://app.turso.io', encryptionKey: 'k1' } }, + // An uppercase https url behind whitespace: the same. + { name: 'edge_https', driver: 'turso', config: { url: ' HTTPS://app.turso.io', encryptionKey: 'k2' } }, + // An unset key beside a local file: every loader dropped it, so it goes. + { name: 'edge_local_unset', driver: 'turso', config: { url: 'file:./data/app.db', encryptionKey: '' } }, + // A non-empty key beside a local file: left for the author. + { name: 'edge_local', driver: 'turso', config: { url: 'file:./data/app.db', encryptionKey: 'k3' } }, + // Another driver's key of the same name: not this contract's — untouched. + { name: 'legacy', driver: 'mysql', config: { url: 'mysql://db/app', encryptionKey: 'k4' } }, + // A turso datasource that never authored the key keeps its identity. + { name: 'edge_replica', driver: 'turso', config: { url: 'libsql://replica.turso.io' } }, + ], + }, + after: { + datasources: [ + { name: 'edge_db', driver: 'turso', config: { url: 'libsql://app.turso.io' } }, + { name: 'edge_https', driver: 'turso', config: { url: ' HTTPS://app.turso.io' } }, + { name: 'edge_local_unset', driver: 'turso', config: { url: 'file:./data/app.db' } }, + { name: 'edge_local', driver: 'turso', config: { url: 'file:./data/app.db', encryptionKey: 'k3' } }, + { name: 'legacy', driver: 'mysql', config: { url: 'mysql://db/app', encryptionKey: 'k4' } }, + { name: 'edge_replica', driver: 'turso', config: { url: 'libsql://replica.turso.io' } }, + ], + }, + expectedNotices: 3, + }, +}; + /** * `datasources[].config.timeout` → `timeoutMs` for the turso driver (protocol * 18, #15680 for #14478). @@ -18009,6 +18117,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: translationComponentSubmitLabelRemoved, order: 12 }, { conversion: translationPerAppSettingsRemoved, order: 34 }, { conversion: translationWidgetSubCaptionRemoved, order: 55 }, + { conversion: tursoConfigEncryptionKeyRemoved, order: 74 }, { conversion: tursoConfigTimeoutToTimeoutMs, order: 28 }, { conversion: viewItemOwnerHiddenRemoved, order: 37 }, { conversion: viewListFilterableFieldsRemoved, order: 71 }, diff --git a/packages/spec/src/data/datasource-credential-redaction.test.ts b/packages/spec/src/data/datasource-credential-redaction.test.ts index 0bebbc1d24c..64a54f205b5 100644 --- a/packages/spec/src/data/datasource-credential-redaction.test.ts +++ b/packages/spec/src/data/datasource-credential-redaction.test.ts @@ -89,7 +89,7 @@ describe('derivation pin: the moved module derives EXACTLY what the service-data expect(redactableConfigKeys('sqlite-wasm')).toEqual(fallback); expect(redactableConfigKeys('memory')).toEqual(fallback); expect(redactableConfigKeys('not-a-real-driver')).toEqual(fallback); - // A non-string driver value cannot index the still-writable table, and + // A non-string driver value cannot index the retired table, and // must not throw — a stored row's `driver` is whatever was stored. expect(redactableConfigKeys(undefined)).toEqual(fallback); }); @@ -118,6 +118,26 @@ describe('derivation pin: the moved module derives EXACTLY what the service-data expect(refusedCredentialKeys('turso')).toContain('authToken'); }); + // #25050 — the one tombstone that IS a credential's value. `encryptionKey` + // retired, so the derivation excludes it like `timeout`; a row stored before + // the retirement may still hold the key the author typed, and the retired + // list keeps it off the read path. Without that list this test goes red on + // the last two assertions: nothing else names the key for turso. + it('a RETIRED credential tombstone (turso encryptionKey) is excluded from the derivation, yet still redacted', () => { + const tursoShape: any = (getDriverConfigSchema('turso') as any).shape; + const shape = typeof tursoShape === 'function' ? tursoShape() : tursoShape; + expect(String(shape.encryptionKey.description)).toMatch(/^\[REMOVED\] /); + expect(refusedCredentialKeys('turso')).not.toContain('encryptionKey'); + expect(redactableConfigKeys('turso')).toContain('encryptionKey'); + expect(redactableConfigKeys('libsql')).toContain('encryptionKey'); + + const stored = { url: 'libsql://db.turso.io', encryptionKey: 'stored-retired-key-25050', concurrency: 4 }; + const { config, redactedKeys } = redactDatasourceConfig('turso', stored); + expect(config).toEqual({ url: 'libsql://db.turso.io', concurrency: 4 }); + expect(redactedKeys).toEqual(['encryptionKey']); + expect(JSON.stringify(config)).not.toContain('stored-retired-key-25050'); + }); + it('the exclusion is negative, so an UNMARKED z.never() is still a credential', () => { // Fail-safe direction. A future credential slot whose author forgets // `refusedInlineCredentialKey`'s marker must still be scrubbed; only a key @@ -350,8 +370,8 @@ describe('passthrough secret redaction — the nested spellings the key-name scr }); it('drops the binder-slotless client secrets too — proxy, TLS key material, AWS session token', () => { - // Still WRITABLE (the binder has exactly one secret slot — the turso- - // `encryptionKey` posture), but never SERVED: each is honoured (or, for + // Still WRITABLE (the binder has exactly one secret slot), but never + // SERVED: each is honoured (or, for // AWS_SESSION_TOKEN, refused loudly) by mongodb@7.5.0, so serving it back // is a leak under any boundary. const { config, redactedKeys } = redactDatasourceConfig('mongodb', { @@ -661,7 +681,7 @@ describe('driver identity: every accepted spelling of a builtin driver is redact } }); - it('the still-writable credential key is withheld under each accepted spelling and named as withheld', () => { + it('the retired credential key is withheld under each accepted spelling and named as withheld', () => { let judged = 0; for (const [variant, id] of ACCEPTED) { for (const key of stillWritableOf(id)) { @@ -672,13 +692,13 @@ describe('driver identity: every accepted spelling of a builtin driver is redact judged += 1; } } - // Population floor: the still-writable class is not empty today, so a + // Population floor: the retired class is not empty today, so a // derivation that silently found nothing would fail here instead of // passing over zero spellings. expect(judged).toBeGreaterThan(0); }); - it('control: a canonical spelling withholds the still-writable credential key exactly as before', () => { + it('control: a canonical spelling withholds the retired credential key exactly as before', () => { let judged = 0; for (const id of BUILTIN_DRIVER_IDS as readonly string[]) { for (const key of stillWritableOf(id)) { @@ -694,7 +714,7 @@ describe('driver identity: every accepted spelling of a builtin driver is redact it('a crafted driver id answers as a driver with no shipped contract: credentials withheld, never a throw', () => { // Every name an object literal inherits. None is a builtin spelling, so // each must resolve to no driver and take the contract-less fallback, not - // index an inherited member of the still-writable table. + // index an inherited member of the retired table. const crafted = Object.getOwnPropertyNames(Object.prototype); expect(crafted.length).toBeGreaterThan(0); const fallback = redactableConfigKeys('a-driver-with-no-contract'); diff --git a/packages/spec/src/data/datasource-credential-redaction.ts b/packages/spec/src/data/datasource-credential-redaction.ts index e18b80110ad..09a4a27b33a 100644 --- a/packages/spec/src/data/datasource-credential-redaction.ts +++ b/packages/spec/src/data/datasource-credential-redaction.ts @@ -54,15 +54,13 @@ * renamed it — the wizard persists through `metadata.register`, whose * validation is a structural name/label check. So the only place these can * still be found is exactly the place this module reads: a stored row. - * 3. **Credential-shaped keys that are still WRITABLE** ({@link - * STILL_WRITABLE_CREDENTIAL_KEYS}) — today just turso's `encryptionKey`, a - * key the binder has no slot for (#8081 scope item 4, which owns the - * decision about giving it one). Since #25020 it is refused for a local - * database file and honoured by the driver in no mode, but a remote - * datasource still parses with it and a stored row may still carry it. - * Redacting it on READ neither grants nor removes a slot: where the key is - * still writable it stays stored. It simply stops being served back in - * cleartext, which is the one question this module answers. + * 3. **Retired credential-shaped keys** ({@link RETIRED_CREDENTIAL_KEYS}) — + * today just turso's `encryptionKey`. Source 1 cannot see it: it is a + * `retiredKey()` tombstone since #25050, and a tombstone is excluded there + * by design. But a stored row written before the retirement may still + * carry the value, and that value is still a secret the author typed, so + * it stops being served back in cleartext all the same, which is the one + * question this module answers. * * For a driver the platform ships no contract for, source 1 is empty — the * registry is saying "nothing to check against", not "nothing to protect". The @@ -122,24 +120,28 @@ import { getDriverConfigSchema, resolveDriverId } from './driver/config-registry // lives unchanged in `datasource-credential-redaction.test.ts`. /** - * Credential-shaped config keys that remain WRITABLE by deliberate spec choice, - * and so are never found by the `z.never()` derivation. + * RETIRED config keys whose stored values are still credential material, so + * the derivation's tombstone exclusion (source 1) must not let them through. * * `encryptionKey` (turso) was declared an AES-256 key for the local database - * file. #8078 left it writable because the datasource secret binder injects - * exactly one secret slot and `external.credentialsRef` resolution cannot - * target a second one; giving it a slot is #8081 scope item 4 and is NOT - * decided here. Since #25020 the turso contract refuses it for a local or - * replica datasource (the driver's SQLite engine cannot encrypt the file), but - * a remote datasource still parses with it and a stored row may still carry - * it, so it stays on this list. + * file and was honoured in no mode; #25050 retired it (`retiredKey()` on the + * spec contract), so no door accepts it any more. A row stored before that + * may still hold the value the author typed as a key, and serving it back in + * cleartext would be a leak under any boundary, so it stays hidden on read. + * The D2 conversion `turso-config-encryption-key-removed` strips it from a + * converted row where it changed nothing; this list is what covers a row read + * raw, and a local-url row the conversion leaves for the author. + * + * It was `STILL_WRITABLE_CREDENTIAL_KEYS` until #25050, when its one member + * stopped being writable. A credential-shaped key that IS still writable + * belongs in neither this list nor the derivation; it needs its own decision. * * Keyed by CANONICAL driver id and looked up through {@link resolveDriverId}, * exactly like {@link PASSTHROUGH_SECRET_PATHS} below: every spelling the * write door judges against a driver's contract is redacted as that driver, * and an id that resolves to no builtin indexes nothing (#21955). */ -const STILL_WRITABLE_CREDENTIAL_KEYS: Record = { +const RETIRED_CREDENTIAL_KEYS: Record = { turso: ['encryptionKey'], }; @@ -177,7 +179,7 @@ const STILL_WRITABLE_CREDENTIAL_KEYS: Record = { * `options.passphrase` — TLS key material and passphrases, declared in the * client's own OPTIONS table. Still WRITABLE (the binder has exactly one * secret slot — the login password — so there is no working refusal remedy; - * turso-`encryptionKey` posture, #8081 item 4), but never SERVED back: + * #8081 item 4), but never SERVED back: * redacting on read neither grants nor removes the write capability. * - `options.authMechanismProperties.AWS_SESSION_TOKEN` — under MONGODB-AWS * the v7 client throws on it; under any other mechanism nothing reads it. @@ -377,13 +379,13 @@ export function refusedCredentialKeys(driver: unknown): string[] { .map(([key]) => key); } -/** Every config key this module hides for `driver`, canonical + alias + writable-but-secret. */ +/** Every config key this module hides for `driver`, canonical + alias + retired-but-secret. */ export function redactableConfigKeys(driver: unknown): string[] { const derived = refusedCredentialKeys(driver); const canonical = derived.length > 0 ? derived : [...CANONICAL_CREDENTIAL_KEYS]; const id = resolveDriverId(driver); - const stillWritable = id ? (STILL_WRITABLE_CREDENTIAL_KEYS[id] ?? []) : []; - return [...new Set([...canonical, ...FORMER_CREDENTIAL_ALIASES, ...stillWritable])]; + const retired = id ? (RETIRED_CREDENTIAL_KEYS[id] ?? []) : []; + return [...new Set([...canonical, ...FORMER_CREDENTIAL_ALIASES, ...retired])]; } /** diff --git a/packages/spec/src/data/driver/common.zod.ts b/packages/spec/src/data/driver/common.zod.ts index 74f14406cc0..8073d5fe152 100644 --- a/packages/spec/src/data/driver/common.zod.ts +++ b/packages/spec/src/data/driver/common.zod.ts @@ -403,9 +403,8 @@ export const PASSTHROUGH_INLINE_CREDENTIAL_REFUSED = (path: string): string => * secret binder injects exactly ONE secret and that slot is the login * password, so a refusal here would name a remedy that does not exist and * remove the only way to configure an authenticated proxy / passphrase- - * protected key. Same posture as turso's still-writable `encryptionKey` - * (#8078; the binder-slot question is #8081 item 4's, not this refusal's). - * They are redacted on read instead + * protected key (the binder-slot question is #8081 item 4's, not this + * refusal's). They are redacted on read instead * (`data/datasource-credential-redaction.ts`). */ export const MONGO_OPTIONS_CREDENTIAL_PATHS: readonly (readonly string[])[] = [ diff --git a/packages/spec/src/data/driver/driver-placeholder-refusal.test.ts b/packages/spec/src/data/driver/driver-placeholder-refusal.test.ts index c4f6172e43b..bbf909e09c2 100644 --- a/packages/spec/src/data/driver/driver-placeholder-refusal.test.ts +++ b/packages/spec/src/data/driver/driver-placeholder-refusal.test.ts @@ -84,13 +84,9 @@ const FAMILY = [ valid: {}, literal: 'libsql://x.turso.io', placeholder: 'libsql://${TURSO_HOST}' }, { name: 'turso syncUrl', schema: TursoConfigSchema, key: 'syncUrl', valid: { url: 'file:./data/replica.db' }, literal: 'libsql://x.turso.io', placeholder: 'libsql://${TURSO_SYNC_HOST}' }, - // A remote url since #25020: a local or replica mode now refuses ANY - // non-empty `encryptionKey` (its SQLite engine cannot encrypt the file — - // pinned in `turso.test.ts`), so the literal half of this row can only be - // judged where the key still parses. This row pins the value-level - // placeholder judgement, not that a remote mode honours the key: it does not. - { name: 'turso encryptionKey', schema: TursoConfigSchema, key: 'encryptionKey', - valid: { url: 'libsql://x.turso.io' }, literal: 'a'.repeat(32), placeholder: '${TURSO_ENCRYPTION_KEY}' }, + // No `turso encryptionKey` row since #25050: the key is a `retiredKey()` + // tombstone, refused in every mode for every value (`turso.test.ts`), so + // there is no literal left for the placeholder judgement to accept. { name: 'sqlite filename', schema: SqliteConfigSchema, key: 'filename', valid: {}, literal: './data/objectstack.db', placeholder: '${DATA_DIR}/objectstack.db' }, { name: 'sqlite-wasm filename', schema: SqliteWasmConfigSchema, key: 'filename', diff --git a/packages/spec/src/data/driver/turso-encryption-key-retirement.test.ts b/packages/spec/src/data/driver/turso-encryption-key-retirement.test.ts new file mode 100644 index 00000000000..cba68407233 --- /dev/null +++ b/packages/spec/src/data/driver/turso-encryption-key-retirement.test.ts @@ -0,0 +1,345 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The turso datasource's `encryptionKey` RETIRED (#25050) — ADR-0049 + * enforce-or-remove. + * + * The key promised an AES-256 key for the local database file and was honoured + * in no mode: beside a `file:` url or `:memory:` the local engine has no + * encryption codec (refused there since #25020), and beside a remote url the + * driver parsed it and built its libSQL client without it. Measured before + * removal: no example, template, CLI scaffold, published skill, host default or + * environment variable authors it. + * + * Bookkeeping shapes, pinned below (the refusal itself — every mode, every + * value, both authoring doors, the tsc channel — is pinned in `turso.test.ts`; + * the driver's mirror and constructor in `@objectstack/driver-turso`'s + * `turso-config-constructor-parity.test.ts`): + * 1. D2 conversion `turso-config-encryption-key-removed` (step 18), retired + * from the load path, and PARTIAL on purpose: it strips the key only + * where that changes nothing — an unset value, or a remote url — and + * leaves a non-empty key beside a local url for the author. + * 2. `RETIRED_KEYS_BY_MAJOR[18]` carries `data/TursoConfig:encryptionKey`; + * the D3 entry is `turso-config-encryption-key-retired`. + * 3. A tree-scoped absence pin over the declared radius. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { collectConversionNotices } from '../../conversions/apply'; +import { ALL_CONVERSIONS } from '../../conversions/registry'; +import { applyConversionsToStoredItem } from '../../conversions/stored'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../../migrations/registry'; +import { DatasourceSchema } from '../datasource.zod'; + +const KEY = 'encryptionKey'; +const CONVERSION_ID = 'turso-config-encryption-key-removed'; +const D3_ID = 'turso-config-encryption-key-retired'; + +type Notice = { conversionId?: string; path?: string; from?: string; to?: string }; + +/** A stored datasource row, as the rehydration seam reads it. */ +const row = (url: string, extra: Record = {}) => ({ + name: 'edge_db', + label: 'Edge', + driver: 'turso', + config: { url, ...extra }, +}); + +const rehydrate = (item: Record) => { + const notices: Notice[] = []; + const out = applyConversionsToStoredItem('datasource', item, { + onNotice: (n) => notices.push(n as Notice), + }) as Record; + return { out, mine: notices.filter((n) => n.conversionId === CONVERSION_ID) }; +}; + +describe('encryptionKey retirement — the D2 conversion', () => { + it('a STORED remote row carrying the key replays clean: stripped, and what the write door accepts now', () => { + for (const url of ['libsql://db.turso.io', 'https://db.turso.io', ' WSS://db.turso.io', 'http://127.0.0.1:8080']) { + const stored = row(url, { encryptionKey: 'stored-25050', concurrency: 4 }); + const before = DatasourceSchema.safeParse(stored); + expect(before.success, url).toBe(false); + + const { out, mine } = rehydrate(stored); + expect(mine.map((n) => [n.from, n.to, n.path?.endsWith('.config.encryptionKey')]), url).toEqual([ + [KEY, '(removed)', true], + ]); + expect((out.config as Record)[KEY], url).toBeUndefined(); + // CONTROL: every other key on the row survives byte-for-byte. + expect(out).toEqual(row(url, { concurrency: 4 })); + // The leg above is a reading, not a tautology: the converted row parses + // only because whitespace in the url is the one thing left to judge. + if (url.trim() === url) expect(DatasourceSchema.safeParse(out).success, url).toBe(true); + } + }); + + it('strips an UNSET value beside a local url too — every loader already dropped it', () => { + for (const value of ['', null, 0]) { + const { out, mine } = rehydrate(row('file:./data/app.db', { encryptionKey: value })); + expect(mine, String(value)).toHaveLength(1); + expect(out).toEqual(row('file:./data/app.db')); + } + }); + + it('LEAVES a non-empty key beside a local url for the author — that operator believed the file encrypted', () => { + for (const config of [ + { url: 'file:./data/app.db', encryptionKey: 'k' }, + { url: ':memory:', encryptionKey: 'k' }, + { url: 'file:./data/replica.db', syncUrl: 'libsql://db.turso.io', encryptionKey: 'k' }, + { url: 'file:./data/app.db', mode: 'remote', encryptionKey: 'k' }, + ]) { + const stored = { name: 'edge_db', label: 'Edge', driver: 'turso', config }; + const { out, mine } = rehydrate(stored); + expect(mine, JSON.stringify(config)).toHaveLength(0); + expect(out, JSON.stringify(config)).toEqual(stored); + // Still refused where it is written, with the retirement's prescription. + expect(JSON.stringify(DatasourceSchema.safeParse(out).error?.issues)).toContain( + '`turso config.encryptionKey` was removed', + ); + } + }); + + it("never touches another driver's key of the same name, and resolves the `libsql` alias as turso", () => { + const other = { name: 'warehouse', label: 'W', driver: 'mysql', config: { url: 'mysql://db/app', encryptionKey: 'k' } }; + expect(rehydrate(other).mine).toHaveLength(0); + const alias = { ...row('libsql://db.turso.io', { encryptionKey: 'k' }), driver: 'libsql' }; + expect(rehydrate(alias).mine).toHaveLength(1); + }); + + it('is idempotent, and retired from the load path — a live author is refused at parse, never rewritten', () => { + const input = { datasources: [row('libsql://db.turso.io', { encryptionKey: 'k' })] }; + const once = collectConversionNotices(input, { includeRetired: true }); + expect(once.notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(1); + const replay = collectConversionNotices(once.stack, { includeRetired: true }); + expect(replay.notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + + const live = collectConversionNotices(input); + expect(live.notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(live.stack).toEqual(input); + }); +}); + +describe('encryptionKey retirement — ADR-0087 registration', () => { + it('declares the key under major 18, once', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('data/TursoConfig:encryptionKey'); + const all = Object.values(RETIRED_KEYS_BY_MAJOR).flat(); + expect(all.filter((k) => k === 'data/TursoConfig:encryptionKey')).toHaveLength(1); + }); + + it('wires the D2 conversion into the step-18 chain as a retired, stamped strip', () => { + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain(CONVERSION_ID); + const conversion = ALL_CONVERSIONS.find((c) => c.id === CONVERSION_ID); + expect(conversion, 'the D2 conversion must be registered').toBeDefined(); + expect(conversion!.toMajor).toBe(18); + expect(conversion!.retiredFromLoadPath).toBe(true); + expect(conversion!.retiredAfter).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('carries ONE D3 entry, naming its D2 conversion and the local-url residue', () => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === D3_ID); + expect(entries).toHaveLength(1); + const [entry] = entries; + expect(entry!.reason).toContain(CONVERSION_ID); + expect(entry!.replacement).toContain('delete encryptionKey'); + expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); + }); +}); + +// ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── +// +// `tsc` is the primary sweeper: the tombstone types the key as the retired mark +// on `TursoConfig`'s input, and `@objectstack/driver-turso` types +// `TursoDriverConfig.encryptionKey` as `never`, so every TYPED authoring site +// fails to compile. The residue is what `tsc` never judges: JSON, YAML, MD/MDX +// code fences, untyped `.js`, and TS literals typed `any` / `unknown` / a +// record — a stored-row fixture is exactly that shape. This walk covers it +// across the roots `scripts/cross-package-test-inputs.mjs` already declares +// for `@objectstack/spec#test`, plus the example apps' own `src/` trees. +// +// `encryptionKey` is NOT a distinctive name — other subsystems may name a +// secret that way — so the matcher judges it in KEY POSITION only (string and +// comment contents masked first, a quoted key kept for JSON), and every +// structural exclusion below says why its file spells the key. The bound, +// stated: a key under a computed name or assembled by spread, and `docs/**`, +// `.claude/**`, `.github/**`, `.changeset/**` and the repo-root files are +// outside what this walk sees. +describe('tree-scoped absence: nothing inside the declared radius still authors a turso encryptionKey', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` the non-code extensions, plus `.ts` inside an app's own `src/` tree. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + /** The retired key in key position — never a tombstone declaration itself. */ + const RETIRED_KEY = /(^|[\s{,(])["']?encryptionKey["']?\s*:(?!\s*(?:retiredKey\(|z\.never\())/m; + const YAML_KEY = /^[ \t]*(?:-[ \t]+)?encryptionKey[ \t]*:/m; + + /** + * Code with string and comment CONTENTS blanked (same length, so offsets + * hold). A quoted string followed by `:` is a key and is kept — that is how + * JSON and quoted TS keys stay visible. A template literal is blanked whole. + */ + const maskCode = (src: string): string => { + const out = src.split(''); + const blank = (from: number, to: number) => { + for (let k = from; k < to; k += 1) if (out[k] !== '\n') out[k] = ' '; + }; + let i = 0; + while (i < src.length) { + const c = src[i]!; + const next = src[i + 1]; + if (c === '/' && next === '/') { + const end = src.indexOf('\n', i); + const stop = end === -1 ? src.length : end; + blank(i, stop); + i = stop; + } else if (c === '/' && next === '*') { + const end = src.indexOf('*/', i + 2); + const stop = end === -1 ? src.length : end + 2; + blank(i, stop); + i = stop; + } else if (c === '"' || c === "'" || c === '`') { + let j = i + 1; + while (j < src.length && src[j] !== c && (c === '`' || src[j] !== '\n')) j += src[j] === '\\' ? 2 : 1; + const close = Math.min(j, src.length - 1); + const isKey = c !== '`' && /^\s*:/.test(src.slice(close + 1, close + 40)); + if (!isKey) blank(i + 1, close); + i = close + 1; + } else { + i += 1; + } + } + return out.join(''); + }; + + const FENCE = /^```([\w-]*)[^\n]*\n([\s\S]*?)^```/gm; + const judge = (text: string, ext: string): boolean => { + if (ext === '.yaml' || ext === '.yml') return YAML_KEY.test(text); + if (ext === '.md' || ext === '.mdx') { + for (const m of text.matchAll(FENCE)) { + const lang = m[1]!.toLowerCase(); + if (lang === 'yaml' || lang === 'yml' ? YAML_KEY.test(m[2]!) : RETIRED_KEY.test(maskCode(m[2]!))) return true; + } + return false; + } + return RETIRED_KEY.test(maskCode(text)); + }; + + /** + * Structural exclusions, each with its reason. ⛔ NOT an allowlist file + * (`spec-property-retirement` §4): every entry's JOB is to spell the key. + */ + const EXCLUDED = new Set([ + // This pin authors the key to assert its conversion. + THIS_FILE, + // The refusal pins: every mode, every value, both authoring doors. + 'packages/spec/src/data/driver/turso.test.ts', + // The stored-row redaction proofs: a row written before the retirement + // still holds the value, and the read path must still withhold it. + 'packages/spec/src/data/datasource-credential-redaction.test.ts', + 'packages/spec/src/kernel/metadata-type-redaction.test.ts', + 'packages/services/service-datasource/src/__tests__/datasource-config-redaction.test.ts', + 'packages/metadata-protocol/src/protocol.data-door-stored-metadata-redaction.test.ts', + // The driver's refusal pins: the constructor and the mirror against the + // spec contract. + 'packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts', + 'packages/drivers/driver-turso/src/turso-driver-encryption-key-retired.test.ts', + // The loader's reader forwards a stored value to the constructor's + // refusal (never honoured), and its pin authors the stored row. + 'packages/services/service-datasource/src/turso-driver-config.ts', + 'packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts', + // The credential-migration planner's pin: a legacy row holding the + // retired key is reported, never bound. + 'packages/services/service-datasource/src/__tests__/datasource-credential-migration.test.ts', + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement datasource on purpose. + 'packages/spec/src/conversions/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // GITIGNORED build output, reached only because this is a FILESYSTEM walk. + 'packages/spec/json-schema/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** Tolerates ONLY a path that vanished mid-walk; every other read fault is re-raised. */ + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises an authoring and ignores a mention and the tombstones (anti-vacuity)', () => { + // Offenders — the retired shape, in each syntax the walk reads. + expect(judge("const ds = { driver: 'turso', config: { url: 'libsql://x', encryptionKey: 'k' } };", '.ts')).toBe(true); + expect(judge('{ "driver": "turso", "config": { "url": "libsql://x", "encryptionKey": "k" } }', '.json')).toBe(true); + expect(judge('config:\n url: libsql://x\n encryptionKey: k\n', '.yaml')).toBe(true); + expect(judge("Prose.\n\n```ts\nencryptionKey: 'k',\n```\n", '.mdx')).toBe(true); + expect(judge('Prose.\n\n```yaml\nencryptionKey: k\n```\n', '.md')).toBe(true); + // Neighbours that must NOT match: the tombstones, an optional-never type, + // comments, strings, prose, the surface ledger row. + expect(judge('encryptionKey: retiredKey(TURSO_ENCRYPTION_KEY_RETIRED),', '.ts')).toBe(false); + expect(judge('encryptionKey: z.never({ error: () => X }).optional(),', '.ts')).toBe(false); + expect(judge('encryptionKey?: never;', '.ts')).toBe(false); + expect(judge("// it once wrote { encryptionKey: 'k' }\nconst x = 1;", '.ts')).toBe(false); + expect(judge("const s = \"{ encryptionKey: 'k' }\";", '.ts')).toBe(false); + expect(judge('Delete `encryptionKey` from every turso datasource.', '.mdx')).toBe(false); + expect(judge('"data/TursoConfig:encryptionKey [RETIRED]",', '.json')).toBe(false); + }); + + it('no encryptionKey authoring survives inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 0; + let exampleSources = 0; + let driverFiles = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + if (EXAMPLE_APP_SRC_TS.test(rel)) exampleSources += 1; + if (rel.startsWith('packages/drivers/driver-turso/src/')) driverFiles += 1; + const text = readIfPresent(full); + if (text === undefined || !text.includes(KEY)) continue; + if (judge(text, ext)) offenders.push(rel); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree, the example apps' + // sources were really read, and the driver package the key lived in was + // really reached. + expect(visited).toBeGreaterThan(1000); + expect(exampleSources).toBeGreaterThan(50); + expect(driverFiles).toBeGreaterThan(10); + expect(offenders, 'a turso encryptionKey authoring means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/data/driver/turso.test.ts b/packages/spec/src/data/driver/turso.test.ts index 86c80898ace..6a72fad168f 100644 --- a/packages/spec/src/data/driver/turso.test.ts +++ b/packages/spec/src/data/driver/turso.test.ts @@ -14,7 +14,7 @@ import { describe, expect, it } from 'vitest'; import { findClosestMatches } from '../../shared/suggestions.zod'; import { DatasourceSchema } from '../datasource.zod'; import { validateDriverConfig } from './config-registry.zod'; -import { TursoConfigSchema, TursoDriverSpec } from './turso.zod'; +import { TursoConfigSchema, TursoDriverSpec, type TursoConfig } from './turso.zod'; describe('TursoConfigSchema', () => { it('accepts the shapes the driver actually connects with', () => { @@ -471,202 +471,6 @@ describe('TursoConfigSchema refuses what the turso driver refuses', () => { }); }); - // #25020 — a declared encryption nothing applies. The driver reads and writes - // a local or replica file through better-sqlite3, which has no encryption - // codec, so a datasource that declared `encryptionKey` was written in - // plaintext that any SQLite reader opened without the key. Both doors now - // refuse it, on `encryptionKey`, in one message per mode. A remote mode is - // not judged by this arm. - describe('a non-empty `encryptionKey` in a local or replica mode — refused on `encryptionKey`', () => { - const LOCAL_FIRST_SENTENCE = - "`encryptionKey` asks for this datasource's database file to be encrypted, but this datasource is a local " - + 'database, which runs every read and write through a local SQLite engine that has no encryption codec: ' - + 'nothing would encrypt the file, so it would be written in plaintext that any SQLite reader opens without ' - + 'the key — the turso driver refuses this configuration when it starts.'; - const REPLICA_FIRST_SENTENCE = - "`encryptionKey` asks for this datasource's database file to be encrypted, but this datasource is an " - + 'embedded replica, which runs every read and write through a local SQLite engine that has no encryption ' - + 'codec (the sync client shares that one file, and a file it encrypted could not be opened by that ' - + 'engine): nothing would encrypt the file, so it would be written in plaintext that any SQLite reader ' - + 'opens without the key — the turso driver refuses this configuration when it starts.'; - const WAY_OUT = 'Drop `encryptionKey`, and encrypt the disk or volume the file lives on instead'; - const KEY = 'test-key-25020'; - - it('a local database — a `file:` url in any case, `:memory:`, a forced local mode', () => { - for (const config of [ - { url: 'file:./data/app.db', encryptionKey: KEY }, - { url: 'FILE:./data/app.db', encryptionKey: KEY }, - { url: ' file:./data/app.db', encryptionKey: KEY }, - { url: ':memory:', encryptionKey: KEY }, - { url: 'file:./data/app.db', mode: 'local', encryptionKey: KEY }, - { url: 'file:./data/app.db', mode: 'local', syncUrl: '', encryptionKey: KEY }, - ]) { - const { path, message } = refusal(config); - expect(path, JSON.stringify(config)).toBe('encryptionKey'); - expect(message.startsWith(LOCAL_FIRST_SENTENCE), message).toBe(true); - expect(message).toContain(WAY_OUT); - } - }); - - it('an embedded replica — selected by `syncUrl` or forced, whatever it syncs from', () => { - for (const config of [ - { url: 'file:./data/replica.db', syncUrl: 'libsql://db.turso.io', encryptionKey: KEY }, - { url: 'file:./data/replica.db', syncUrl: 'libsql://db.turso.io', sync: { onConnect: false }, encryptionKey: KEY }, - { url: 'file:./data/replica.db', syncUrl: 'wss://db.turso.io', timeoutMs: 5000, encryptionKey: KEY }, - { url: 'file:./data/replica.db', mode: 'replica', syncUrl: 'libsql://db.turso.io', encryptionKey: KEY }, - ]) { - const { path, message } = refusal(config); - expect(path, JSON.stringify(config)).toBe('encryptionKey'); - expect(message.startsWith(REPLICA_FIRST_SENTENCE), message).toBe(true); - expect(message).toContain(WAY_OUT); - } - }); - - it('never echoes the key or either url', () => { - const { message } = refusal({ - url: 'file:./private-dir/replica.db', - syncUrl: 'libsql://private-host.turso.io', - encryptionKey: 'private-key-value', - }); - expect(message).not.toContain('private-dir'); - expect(message).not.toContain('private-host'); - expect(message).not.toContain('private-key-value'); - }); - - it('comes last: a url, a forced mode or a lone `sync` another refusal takes keeps that refusal', () => { - for (const [config, onKey] of [ - [{ url: './data/app.db', encryptionKey: KEY }, 'url'], - [{ url: 'libsql://db.turso.io', syncUrl: 'libsql://db.turso.io', encryptionKey: KEY }, 'url'], - [{ url: ':memory:', syncUrl: 'libsql://db.turso.io', encryptionKey: KEY }, 'url'], - [{ url: 'file:./data/replica.db', mode: 'replica', encryptionKey: KEY }, 'mode'], - [{ url: 'file:./data/app.db', mode: 'local', syncUrl: 'libsql://db.turso.io', encryptionKey: KEY }, 'mode'], - ] as const) { - const { path } = refusal(config); - expect(path, JSON.stringify(config)).toBe(onKey); - } - // A lone `sync` is the refinement's own first issue; this arm stands beside it, second. - const result = TursoConfigSchema.safeParse({ url: 'file:./data/app.db', sync: { intervalSeconds: 60 }, encryptionKey: KEY }); - expect(result.success).toBe(false); - expect(result.error!.issues.map((i) => i.path.join('.'))).toEqual(['sync', 'encryptionKey']); - }); - - it('reaches both authoring doors: `DatasourceSchema` (re-pathed under `config`) and `validateDriverConfig`', () => { - const config = { url: 'file:./data/app.db', encryptionKey: KEY }; - const datasource = DatasourceSchema.safeParse({ name: 'edge', driver: 'turso', config }); - expect(datasource.success).toBe(false); - const issue = datasource.error!.issues.find((i) => i.path.join('.') === 'config.encryptionKey'); - expect(issue, JSON.stringify(datasource.error!.issues)).toBeDefined(); - expect(issue!.message.startsWith(LOCAL_FIRST_SENTENCE)).toBe(true); - - const verdict = validateDriverConfig('turso', config); - expect(verdict.known).toBe(true); - expect(verdict.known && verdict.issues.map((i) => i.path.join('.'))).toEqual(['encryptionKey']); - }); - - it('controls: an empty `encryptionKey` is unset, and a local or replica config without one stays accepted', () => { - for (const config of [ - { url: 'file:./data/app.db', encryptionKey: '' }, - { url: 'file:./data/replica.db', syncUrl: 'libsql://db.turso.io', encryptionKey: '' }, - { url: 'file:./data/app.db' }, - { url: 'file:./data/replica.db', syncUrl: 'libsql://db.turso.io' }, - ]) { - const result = TursoConfigSchema.safeParse(config); - expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); - } - }); - - it('the describe no longer promises an encrypted file', () => { - const description = TursoConfigSchema.shape.encryptionKey.description ?? ''; - expect(description).not.toMatch(/AES-256/); - expect(description).toContain('refused for a local or replica datasource'); - expect(description).toContain('under a forced remote mode'); - }); - }); - - // #25020, patch round 1 — the same key beside a local url under a FORCED - // `mode: 'remote'`. The forced mode skipped every local-engine refusal, and - // `connect()` handed the url to the libSQL client the driver builds, which is - // handed no key: plaintext all the same. Judged by where the database lives, - // not by the mode, in its own words. - describe("a non-empty `encryptionKey` beside a `file:` url or `:memory:` under a forced `mode: 'remote'` — refused on `encryptionKey`", () => { - const FILE_FIRST_SENTENCE = - "`encryptionKey` asks for this datasource's database file to be encrypted, but `url` is a local `file:` " - + "url, and `mode: 'remote'` runs every read and write of that file through the libSQL client this driver " - + 'builds, which is handed no key: nothing would encrypt the file, so it would be written in plaintext that ' - + 'any SQLite reader opens without the key — the turso driver refuses this configuration when it starts.'; - const MEMORY_FIRST_SENTENCE = - "`encryptionKey` asks for this datasource's database to be encrypted, but `url` names an in-memory " - + "database, and `mode: 'remote'` runs every read and write of it through the libSQL client this driver " - + 'builds, which is handed no key: there is no file for the key to encrypt, so it would change nothing — ' - + 'the turso driver refuses this configuration when it starts.'; - const KEY = 'test-key-25020'; - - it('a `file:` url in any case, behind whitespace, beside `timeoutMs`', () => { - for (const config of [ - { url: 'file:./data/app.db', mode: 'remote', encryptionKey: KEY }, - { url: 'FILE:./data/app.db', mode: 'remote', encryptionKey: KEY }, - { url: ' file:./data/app.db', mode: 'remote', encryptionKey: KEY }, - { url: 'file:./data/app.db', mode: 'remote', timeoutMs: 5000, encryptionKey: KEY }, - ]) { - const { path, message } = refusal(config); - expect(path, JSON.stringify(config)).toBe('encryptionKey'); - expect(message.startsWith(FILE_FIRST_SENTENCE), message).toBe(true); - expect(message).toContain('Drop `encryptionKey`, and encrypt the disk or volume the file lives on instead'); - } - }); - - it('an in-memory url — `:memory:`, `file::memory:`, a `:memory:?` query — in its own words', () => { - for (const url of [':memory:', 'file::memory:', 'FILE::memory:', 'file::memory:?cache=shared']) { - const { path, message } = refusal({ url, mode: 'remote', encryptionKey: KEY }); - expect(path, url).toBe('encryptionKey'); - expect(message.startsWith(MEMORY_FIRST_SENTENCE), message).toBe(true); - } - }); - - it('never echoes the key or the url', () => { - const { message } = refusal({ url: 'file:./private-dir/app.db', mode: 'remote', encryptionKey: 'private-key-value' }); - expect(message).not.toContain('private-dir'); - expect(message).not.toContain('private-key-value'); - }); - - it('comes last in the remote arm: a `syncUrl` or a lone `sync` keeps its own refusal, first', () => { - for (const [config, first] of [ - [{ url: 'file:./data/app.db', mode: 'remote', syncUrl: 'libsql://db.turso.io', encryptionKey: KEY }, 'syncUrl'], - [{ url: 'file:./data/app.db', mode: 'remote', sync: { onConnect: true }, encryptionKey: KEY }, 'sync'], - ] as const) { - const result = TursoConfigSchema.safeParse(config); - expect(result.success).toBe(false); - expect(result.error!.issues.map((i) => i.path.join('.')), JSON.stringify(config)).toEqual([first, 'encryptionKey']); - } - }); - - it('reaches both authoring doors: `DatasourceSchema` (re-pathed under `config`) and `validateDriverConfig`', () => { - const config = { url: 'file:./data/app.db', mode: 'remote', encryptionKey: KEY }; - const datasource = DatasourceSchema.safeParse({ name: 'edge', driver: 'turso', config }); - expect(datasource.success).toBe(false); - const issue = datasource.error!.issues.find((i) => i.path.join('.') === 'config.encryptionKey'); - expect(issue, JSON.stringify(datasource.error!.issues)).toBeDefined(); - expect(issue!.message.startsWith(FILE_FIRST_SENTENCE)).toBe(true); - - const verdict = validateDriverConfig('turso', config); - expect(verdict.known).toBe(true); - expect(verdict.known && verdict.issues.map((i) => i.path.join('.'))).toEqual(['encryptionKey']); - }); - - it('controls: no key or an empty one, and a remote url with a key (not judged by this card), stay accepted', () => { - for (const config of [ - { url: 'file:./data/app.db', mode: 'remote' }, - { url: 'file:./data/app.db', mode: 'remote', encryptionKey: '' }, - { url: ':memory:', mode: 'remote' }, - { url: 'libsql://db.turso.io', encryptionKey: KEY }, - { url: 'libsql://db.turso.io', mode: 'remote', encryptionKey: KEY }, - ]) { - const result = TursoConfigSchema.safeParse(config); - expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); - } - }); - }); - it('the pre-existing `sync`-without-`syncUrl` refusal is unchanged, and stands beside a transport refusal', () => { const result = TursoConfigSchema.safeParse({ url: './data/app.db', sync: { intervalSeconds: 60 } }); expect(result.success).toBe(false); @@ -698,3 +502,124 @@ describe('TursoConfigSchema refuses what the turso driver refuses', () => { expect(description).not.toContain('a file path'); }); }); + +// #25050 — `encryptionKey` is RETIRED (ADR-0049 enforce-or-remove). It was +// honoured in no mode: #25020 refused it beside a `file:` url or `:memory:`, +// where the local engine has no encryption codec, and on a remote url the +// driver parsed it and never handed it on. The tombstone refuses every value, +// in every mode, at the key itself — ahead of every transport refusal — with +// one prescription that `@objectstack/driver-turso`'s mirror and constructor +// repeat byte for byte (`turso-config-constructor-parity.test.ts` there). +describe('TursoConfig.encryptionKey is retired', () => { + /** A test value only — never a real key. */ + const KEY = 'test-key-25050'; + const PRESCRIPTION = /`turso config\.encryptionKey` was removed in @objectstack\/spec 18.*Delete the key\./s; + + /** The one issue a config earns, asserted to be the only one there is. */ + const onlyIssue = (config: Record) => { + const result = TursoConfigSchema.safeParse(config); + expect(result.success, JSON.stringify(config)).toBe(false); + const issues = result.error!.issues; + expect(issues, JSON.stringify(issues)).toHaveLength(1); + return issues[0]; + }; + + it('refuses the key in every mode, at the key, with the prescription — remote urls included', () => { + for (const config of [ + // Remote: parsed and dropped by the driver until this retirement. + { url: 'libsql://db.turso.io', encryptionKey: KEY }, + { url: 'https://db.turso.io', encryptionKey: KEY }, + { url: 'wss://db.turso.io', encryptionKey: KEY }, + { url: 'libsql://db.turso.io', mode: 'remote', encryptionKey: KEY }, + // Local and replica: refused since #25020, now by the tombstone. + { url: 'file:./data/app.db', encryptionKey: KEY }, + { url: ':memory:', encryptionKey: KEY }, + { url: 'file:./data/app.db', mode: 'local', encryptionKey: KEY }, + { url: 'file:./data/replica.db', syncUrl: 'libsql://db.turso.io', encryptionKey: KEY }, + { url: 'file:./data/app.db', mode: 'remote', encryptionKey: KEY }, + ]) { + const issue = onlyIssue(config); + expect(issue.path.join('.'), JSON.stringify(config)).toBe('encryptionKey'); + expect(issue.message).toMatch(PRESCRIPTION); + } + }); + + it('refuses every value — an empty string is no longer "unset", and a non-string is no escape', () => { + for (const encryptionKey of ['', KEY, 'a'.repeat(32), '${TURSO_ENCRYPTION_KEY}', 0, null, {}]) { + const issue = onlyIssue({ url: 'libsql://db.turso.io', encryptionKey }); + expect(issue.path.join('.')).toBe('encryptionKey'); + expect(issue.message).toMatch(PRESCRIPTION); + } + }); + + it('comes first: a config that is wrong in another way as well meets the retirement, alone', () => { + // The tombstone fails the shape, so no transport refusal is reached — + // the constructor raises the same refusal first, for the same reason. + for (const config of [ + { url: './data/app.db', encryptionKey: KEY }, + { url: 'libsql://db.turso.io', syncUrl: 'libsql://db.turso.io', encryptionKey: KEY }, + { url: 'file:./data/app.db', sync: { intervalSeconds: 60 }, encryptionKey: KEY }, + { url: 'file:./data/replica.db', mode: 'replica', encryptionKey: KEY }, + { url: 'wss://db.turso.io', timeoutMs: 5000, encryptionKey: KEY }, + ]) { + expect(onlyIssue(config).path.join('.'), JSON.stringify(config)).toBe('encryptionKey'); + } + }); + + it('never echoes the key or the url', () => { + const { message } = onlyIssue({ url: 'libsql://private-host.turso.io', encryptionKey: 'private-key-value' }); + expect(message).not.toContain('private-key-value'); + expect(message).not.toContain('private-host'); + }); + + it('answers its case and underscore spellings with the same prescription', () => { + // A tombstoned key leaves the unknown-key probe's candidates, so without + // the guidance entries these would get no suggestion at all. + for (const spelling of ['encryption_key', 'encryptionkey']) { + const issue = onlyIssue({ url: 'libsql://db.turso.io', [spelling]: KEY }); + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.message, spelling).toMatch(PRESCRIPTION); + } + }); + + it('reaches both authoring doors: `DatasourceSchema` (re-pathed under `config`) and `validateDriverConfig`', () => { + const config = { url: 'libsql://db.turso.io', encryptionKey: KEY }; + const datasource = DatasourceSchema.safeParse({ name: 'edge', driver: 'turso', config }); + expect(datasource.success).toBe(false); + const issue = datasource.error!.issues.find((i) => i.path.join('.') === 'config.encryptionKey'); + expect(issue, JSON.stringify(datasource.error!.issues)).toBeDefined(); + expect(issue!.message).toMatch(PRESCRIPTION); + + const verdict = validateDriverConfig('turso', config); + expect(verdict.known).toBe(true); + expect(verdict.known && verdict.issues.map((i) => i.path.join('.'))).toEqual(['encryptionKey']); + }); + + it('the describe is the tombstone, carrying the same prescription', () => { + const description = TursoConfigSchema.shape.encryptionKey.description ?? ''; + expect(description.startsWith('[REMOVED] ')).toBe(true); + expect(description.slice('[REMOVED] '.length)).toMatch(PRESCRIPTION); + }); + + it('controls: every mode without the key stays accepted', () => { + for (const config of [ + { url: 'libsql://db.turso.io' }, + { url: 'file:./data/app.db' }, + { url: ':memory:' }, + { url: 'file:./data/replica.db', syncUrl: 'libsql://db.turso.io' }, + { url: 'file:./data/app.db', mode: 'remote' }, + ]) { + const result = TursoConfigSchema.safeParse(config); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + } + }); + + it('is a tsc error at a typed authoring site', () => { + const typed: TursoConfig = { + url: 'libsql://db.turso.io', + // @ts-expect-error — `encryptionKey` is a `retiredKey()` tombstone: no value is assignable. + encryptionKey: KEY, + }; + expect(TursoConfigSchema.safeParse(typed).success).toBe(false); + }); +}); diff --git a/packages/spec/src/data/driver/turso.zod.ts b/packages/spec/src/data/driver/turso.zod.ts index 983deddfdd2..9728ead8fe4 100644 --- a/packages/spec/src/data/driver/turso.zod.ts +++ b/packages/spec/src/data/driver/turso.zod.ts @@ -109,22 +109,13 @@ export type TursoTransportMode = z.input; // embedded replica, syncing with the remote on connect and on the interval // while its sync-enabled check answered true. Authoring and the constructor // now refuse it together, in this arm's words; -// - a non-empty `encryptionKey` in a local or replica mode (#25020): the -// driver runs every read and write of the file through better-sqlite3, -// which has no encryption codec (`PRAGMA key` is a silent no-op there), so -// the key encrypted nothing and the file was written in plaintext that any -// SQLite reader opened without it. An embedded replica is no different: -// only its sync client is handed the key, and a file that client encrypted -// is one better-sqlite3 cannot open. Authoring and the constructor now -// refuse it together, in this arm's words, last among the local-engine -// refusals; -// - the same key beside a `file:` url or `:memory:` under a forced -// `mode: 'remote'` (#25020, patch round 1): the key is judged by where the -// database lives, not by the mode. `connect()` hands that url to the -// libSQL client this driver builds, which is handed no key, so the file -// was written in plaintext all the same. Refused last in the remote arm, -// in its own words (one text for a file, one for an in-memory database). -// A remote url (`libsql://`, `https://`, `ws(s)://`) is not judged here. +// +// `encryptionKey` is no longer judged here. #25020 refused it on a local or +// replica datasource and beside a `file:` url or `:memory:` under a forced +// `mode: 'remote'`, and left a remote url, where the driver dropped it; #25050 +// retired the key outright (a `retiredKey()` tombstone on the shape below), +// so every value is refused at the key itself, in every mode, before any of +// these arms is reached. // // The predicates MIRROR the constructor's on `main` (`localEngineDefect`, // `refuseWebSocketTimeout` and `detectMode` in @@ -180,12 +171,11 @@ interface TursoTransportKeys { syncUrl?: unknown; mode?: TursoTransportMode; timeoutMs?: unknown; - encryptionKey?: unknown; } /** One refusal: the key it sits on and its message. */ interface TursoTransportIssue { - path: 'url' | 'syncUrl' | 'timeoutMs' | 'mode' | 'encryptionKey'; + path: 'url' | 'syncUrl' | 'timeoutMs' | 'mode'; message: string; } @@ -301,26 +291,6 @@ function tursoTransportIssues(cfg: TursoTransportKeys): TursoTransportIssue[] { + "`url: 'file:./data/replica.db'`. For a plain local database, drop `syncUrl` (and `sync`).", }]; } - if (typeof cfg.encryptionKey === 'string' && cfg.encryptionKey.length > 0) { - // #25020. The url and the mode are coherent (every other shape met a - // refusal above), so what the runtime cannot honour is the KEY: the - // local engine has no encryption codec, so the issue sits on - // `encryptionKey`. An empty key is unset, as the loaders read it. - return [{ - path: 'encryptionKey', - message: - `\`encryptionKey\` asks for this datasource's database file to be encrypted, but this datasource is ${arm}, ` - + 'which runs every read and write through a local SQLite engine that has no encryption codec' - + (mode === 'replica' - ? ' (the sync client shares that one file, and a file it encrypted could not be opened by that engine)' - : '') - + ': nothing would encrypt the file, so it would be written in plaintext that any SQLite reader opens ' - + 'without the key — the turso driver refuses this configuration when it starts. Nothing in this driver ' - + 'encrypts a local or replica file. Drop `encryptionKey`, and encrypt the disk or volume the file lives ' - + "on instead, or keep the data in a remote Turso database, whose storage encryption is the server's to " - + "configure, not this key's.", - }]; - } return []; } @@ -349,35 +319,28 @@ function tursoTransportIssues(cfg: TursoTransportKeys): TursoTransportIssue[] { + "file beside `syncUrl`: `url: 'file:./data/replica.db'`.", }); } - if ( - (isFileTursoUrl(url) || url === ':memory:') - && typeof cfg.encryptionKey === 'string' && cfg.encryptionKey.length > 0 - ) { - // #25020, patch round 1. Only a FORCED remote mode reaches here with a - // `file:` url or `:memory:` (with no `mode`, those urls select a local - // engine). The key is judged by where the database lives, not by the - // mode: `connect()` hands that url to the libSQL client this driver - // builds, which is handed no key, so the issue sits on `encryptionKey`, - // last, like the local-engine arm's. - issues.push({ - path: 'encryptionKey', - message: namesInMemoryTursoDatabase(url) - ? "`encryptionKey` asks for this datasource's database to be encrypted, but `url` names an in-memory " - + "database, and `mode: 'remote'` runs every read and write of it through the libSQL client this " - + 'driver builds, which is handed no key: there is no file for the key to encrypt, so it would change ' - + 'nothing — the turso driver refuses this configuration when it starts. Drop `encryptionKey`.' - : "`encryptionKey` asks for this datasource's database file to be encrypted, but `url` is a local " - + "`file:` url, and `mode: 'remote'` runs every read and write of that file through the libSQL client " - + 'this driver builds, which is handed no key: nothing would encrypt the file, so it would be written ' - + 'in plaintext that any SQLite reader opens without the key — the turso driver refuses this ' - + 'configuration when it starts. Nothing in this driver encrypts a local file. Drop `encryptionKey`, ' - + 'and encrypt the disk or volume the file lives on instead, or point `url` at a remote Turso ' - + "database, whose storage encryption is the server's to configure, not this key's.", - }); - } return issues; } +/** + * The prescription the retired `encryptionKey` raises (#25050), at the key on + * the shape below and through two guidance spellings. Copied byte for byte by + * `@objectstack/driver-turso` (its mirror and its constructor), which cannot + * import it: `retiredKey()` and this module are not on the spec's published + * entry points. + * + * ⛔ No internal issue id in the text: it reaches Studio's datasource form and + * an operator's boot log. + */ +const TURSO_ENCRYPTION_KEY_RETIRED = + '`turso config.encryptionKey` was removed in @objectstack/spec 18 and @objectstack/driver-turso 18 ' + + '(ADR-0049) — it never encrypted anything, in any mode: a local or embedded-replica file is read and ' + + 'written through a SQLite engine that has no encryption codec, and the driver never handed the key to ' + + 'a remote database, whose storage encryption the server configures. Delete the key. A local file that ' + + 'must be encrypted at rest belongs on an encrypted disk or volume. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for the unset and remote-url cases; ' + + '`--write` applies the ones it can prove, and you delete the key from a local or replica datasource by hand.'; + export const TursoConfigSchema = lazySchema(() => strictObject( { surface: "this turso datasource's config", @@ -392,8 +355,8 @@ export const TursoConfigSchema = lazySchema(() => strictObject( // row is a PIN on an answer the fallback happens to get right, not a // gap-filler, and it keeps answering `url` if this shape ever gains a key // within 2 of `uri`. `turso.test.ts` pins the distinction. - // Case and underscore variants of a DECLARED key (`encryption_key`, - // `sync_url`) are deliberately absent: the unknown-key probe already + // Case and underscore variants of a DECLARED key (`sync_url`) are + // deliberately absent: the unknown-key probe already // normalizes those onto the declared name, so entries for them would be // alias rows that never fire, and two of them collided with each other on // one probe (`auth_token`/`authtoken`, `sync_interval`/`syncinterval`) — @@ -418,6 +381,12 @@ export const TursoConfigSchema = lazySchema(() => strictObject( jwt: INLINE_CREDENTIAL_REFUSED('jwt'), auth_token: INLINE_CREDENTIAL_REFUSED('auth_token'), authtoken: INLINE_CREDENTIAL_REFUSED('authtoken'), + // #25050 — `encryptionKey` is tombstoned, so it left the probe's + // candidate list exactly as `authToken` did above, and its case and + // underscore variants would get no suggestion at all. They carry the + // retirement's prescription directly instead. + encryption_key: TURSO_ENCRYPTION_KEY_RETIRED, + encryptionkey: TURSO_ENCRYPTION_KEY_RETIRED, pool: '`pool` is not driver config — libSQL sizes remote concurrency with `concurrency`, and ' + "every driver's pooling block lives next to `driver` on the datasource itself.", @@ -488,26 +457,31 @@ export const TursoConfigSchema = lazySchema(() => strictObject( authToken: refusedInlineCredentialKey('authToken', 'Auth token'), /** - * Encrypts nothing, in any mode — and is REFUSED in a local or replica - * mode since #25020, and beside a `file:` url or `:memory:` under a forced - * `mode: 'remote'`, whose libSQL client is handed no key (see - * {@link tursoTransportIssues}). This key used to - * promise "AES-256 key for the local database file", but the driver reads - * and writes that file through better-sqlite3, which has no encryption - * codec: a local datasource that declared it was written in plaintext that - * any SQLite reader opened without it (measured on the driver source), and - * an embedded replica fared no better, because only its sync client was - * handed the key. The driver does not forward it to a remote database, - * whose storage encryption is the server's. + * Tombstone for the REMOVED `encryptionKey` (#25050, ADR-0049 + * enforce-or-remove). It promised "AES-256 key for the local database + * file" and was honoured in no mode: the driver reads and writes a local + * or replica file through better-sqlite3, which has no encryption codec, + * so #25020 refused it there (and beside a `file:` url or `:memory:` under + * a forced `mode: 'remote'`); on a remote url the driver never handed it + * to the libSQL client, so it was parsed and dropped. A declared key the + * runtime honours nowhere is the removal case, so the key goes — as a + * tombstone, so `tsc` sees `never` at every typed authoring site and the + * parse refuses every value with the prescription, in every mode. + * + * The D2 conversion `turso-config-encryption-key-removed` strips it from a + * stored or built datasource only where removing it changes nothing: an + * unset value, or a remote url the driver never handed it to. A non-empty + * key beside a local url is left for the author (the D3 entry + * `turso-config-encryption-key-retired` says why). A stored row that still + * carries one is withheld on every read by `RETIRED_CREDENTIAL_KEYS` in + * `datasource-credential-redaction.ts`: a tombstone is not a credential to + * the schema-derived set, but the value a legacy row holds still is. * - * Still declared (removing it is a key retirement this refusal does not - * make) and still placeholder-free (#8336); the datasource redaction set - * withholds it on every read (`STILL_WRITABLE_CREDENTIAL_KEYS` in - * `datasource-credential-redaction.ts`). + * `@objectstack/driver-turso` refuses the same key in its mirror and its + * constructor, in these words, byte for byte + * (`turso-config-constructor-parity.test.ts` there). */ - encryptionKey: placeholderFree(z.string(), 'encryptionKey').optional() - .describe("Not honoured in any mode: refused for a local or replica datasource, whose SQLite engine cannot encrypt the file, and for a file: url or :memory: under a forced remote mode, whose libSQL client is handed no key; a remote database's storage encryption is the server's, not set by this key") - .meta({ title: 'Encryption key', format: 'password' }), + encryptionKey: retiredKey(TURSO_ENCRYPTION_KEY_RETIRED), /** Max concurrent requests to the remote database (replica/remote modes). */ concurrency: z.number().int().positive().optional() diff --git a/packages/spec/src/kernel/metadata-type-redaction.test.ts b/packages/spec/src/kernel/metadata-type-redaction.test.ts index 838997dd748..0718db10452 100644 --- a/packages/spec/src/kernel/metadata-type-redaction.test.ts +++ b/packages/spec/src/kernel/metadata-type-redaction.test.ts @@ -111,7 +111,7 @@ describe('FAIL-CLOSED: the datasource redactor is a BUILT-IN, not a plugin regis expect(stored.config.url).toBe('postgresql://admin:hunter2@db.internal:5432/app'); }); - it('turso: alias spellings and the still-writable encryptionKey are covered end-to-end', () => { + it('turso: alias spellings and the retired encryptionKey are covered end-to-end', () => { const result = getMetadataTypeRedactor('datasource')!({ name: 't', driver: 'turso', diff --git a/packages/spec/src/kernel/metadata-type-redaction.ts b/packages/spec/src/kernel/metadata-type-redaction.ts index 2a0199a7fa7..121196a7b78 100644 --- a/packages/spec/src/kernel/metadata-type-redaction.ts +++ b/packages/spec/src/kernel/metadata-type-redaction.ts @@ -106,7 +106,8 @@ export type MetadataTypeRedactor = (item: Record) => MetadataRe * The built-in `datasource` redactor: redacts the driver `config` through the * one credential-key definition in `data/datasource-credential-redaction.ts` * (derived from each driver's `z.never()` contract + the pre-#8078 alias list - * + turso's `encryptionKey`), and leaves every other key of the item — + * + turso's retired `encryptionKey`, which a legacy stored row may still + * carry), and leaves every other key of the item — * `_diagnostics` included — byte-for-byte. */ function redactDatasourceItem(item: Record): MetadataRedactionResult { diff --git a/packages/spec/src/migrations/entries/retired-keys/18.data__TursoConfig__encryptionKey.ts b/packages/spec/src/migrations/entries/retired-keys/18.data__TursoConfig__encryptionKey.ts new file mode 100644 index 00000000000..fea20b627bd --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.data__TursoConfig__encryptionKey.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #25050 — `encryptionKey` was honoured in no mode: the local engine has no +// encryption codec (refused there since #25020), and on a remote url the driver +// parsed the key and never handed it to the libSQL client. ADR-0049 +// enforce-or-remove; the D2 conversion `turso-config-encryption-key-removed` +// and the D3 semantic entry `turso-config-encryption-key-retired` carry it. +export const entry = 'data/TursoConfig:encryptionKey'; diff --git a/packages/spec/src/migrations/entries/semantic/18.turso-config-encryption-key-retired.ts b/packages/spec/src/migrations/entries/semantic/18.turso-config-encryption-key-retired.ts new file mode 100644 index 00000000000..72cd56ec2bd --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.turso-config-encryption-key-retired.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The second half of turso-config-local-encryption-key-refused: that entry +// refused the key where the local engine could not honour it, and left a remote +// url, where the driver parsed the key and dropped it. This one retires the key +// in every mode. The D2 conversion carries the bodies where removing it changes +// nothing; a key beside a local url stays the author's call, as it was. +export const entry: SemanticMigration = { + id: 'turso-config-encryption-key-retired', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'data.TursoConfig.encryptionKey (a turso / libsql datasource.config), the published TursoConfigSchema ' + + 'mirror and the TursoDriver constructor of @objectstack/driver-turso — the key is removed: every ' + + 'value is refused, in every mode, at authoring and at construction', + replacement: + 'no key: delete encryptionKey. A remote Turso database keeps its connection exactly as before, since ' + + 'the driver never handed it the key; a local or replica file that must be encrypted at rest lives ' + + 'on an encrypted disk or volume, or the data moves to a remote Turso database, whose storage ' + + 'encryption the server configures', + reason: + 'ADR-0049 enforce-or-remove: a declared key the runtime honours nowhere is the removal case. ' + + 'encryptionKey promised an AES-256 key for the local database file. Beside a file: url or :memory: ' + + 'the local engine (better-sqlite3) has no encryption codec, so it was refused there already ' + + '(turso-config-local-encryption-key-refused). Beside a remote url the driver parsed it and built ' + + 'its libSQL client from url, authToken, concurrency and fetch only, so the key was dropped in ' + + 'silence; the libSQL remote client would itself refuse it as not supported. Refusing it there too ' + + 'and keeping it declared would leave a published slot whose only purpose is to be refused, and a key ' + + 'an AI author still writes, so it is retired instead: a retiredKey() tombstone on the spec contract, ' + + 'a z.never() tombstone on the driver mirror and a constructor refusal in every mode, one text at all ' + + 'three. The D2 conversion turso-config-encryption-key-removed strips it from stored rows, built ' + + 'artifacts and os migrate meta --from 17 output only where that changes nothing: an unset value, ' + + 'which every loader dropped, or a key beside a remote url, which the driver dropped. A non-empty key ' + + 'beside a local url is left: that operator believed the file encrypted, and whether to accept a ' + + 'plaintext file, move it to an encrypted volume or move the data to a remote database is intent no ' + + 'artifact records. Such a stored row keeps failing when its driver is built, as it has since the ' + + 'local refusal. A stored row that still holds a value is withheld on every read, as before. Measured ' + + 'on this tree at the change: no example, template, CLI scaffold, published skill, host default or ' + + 'environment variable authors the key. ADR-0049 / ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Run os migrate meta --from 17 and apply its edits; then validate every stack and re-save every turso ' + + 'datasource: os validate or defineStack, and a save or test connection through the datasource admin ' + + 'service, report any encryptionKey left at config.encryptionKey. Delete each one, and where a local ' + + 'file must be encrypted at rest, put it on an encrypted disk or volume or move the data to a remote ' + + 'Turso database. Done when every turso datasource parses, the driver builds from it, and no turso ' + + 'datasource carries an encryptionKey.', +}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index e3bdf5d4ffc..0385a274dc9 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -2899,6 +2899,20 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'new tombstone, and no D2 conversion exists. Its D3 record is the semantic entry ' + '`try-catch-and-retry-policy-undeclared-keys-refused`.', }, + { + id: 'turso-config-encryption-key-retired', + order: 107, + text: + 'It also retires `encryptionKey` from the turso datasource config (ADR-0049 enforce-or-remove). ' + + 'The key promised an AES-256 key for the local database file and was honoured in no mode: the ' + + 'local engine has no encryption codec, so it was already refused beside a local url, and on a ' + + 'remote url the driver parsed it and never handed it on. It is a `retiredKey()` tombstone on ' + + '`TursoConfigSchema`, a `z.never()` tombstone on `@objectstack/driver-turso`\'s mirror, and a ' + + 'refusal in every mode in `new TursoDriver()`. The D2 conversion ' + + '`turso-config-encryption-key-removed` strips it only where that changes nothing (an unset value, ' + + 'or a remote url), and leaves a key beside a local url for the author; its D3 record is the ' + + 'semantic entry `turso-config-encryption-key-retired`.', + }, { id: 'ui-action-group-menu-member-params-array-only', order: 83, diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index e6d087a409a..6180307e6ab 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -89,6 +89,7 @@ import { ScriptConfigSchema, SubflowConfigSchema } from '../automation/schemales import { AdminScopeSchema, PermissionSetSchema } from '../security/permission.zod'; import { SharingLevel } from '../security/sharing.zod'; import { DatasourceSchema } from '../data/datasource.zod'; +import { TursoConfigSchema } from '../data/driver/turso.zod'; import { PageComponentSchema, PageSchema } from '../ui/page.zod'; import { AddRecordConfigSchema, FormFieldSchema, FormSectionSchema, ListViewSchema, ObjectListViewSchema } from '../ui/view.zod'; import { FlowSchema } from '../automation/flow.zod'; @@ -355,6 +356,10 @@ const JOIN: Readonly> = { 'add-record-enabled-defaulted': [ refusal('an addRecord block without `enabled`', () => AddRecordConfigSchema.safeParse({}), 'enabled'), ], + // #25050: retired after 18.0.0, so the v18 train's major. Partial (unset + // values and remote urls only), so the prescription's migrate sentence is + // the two-clause form; the release it names is judged the same way. + 'turso-config-encryption-key-removed': [tombstone(TursoConfigSchema, 'turso config', 'encryptionKey')], 'view-list-filterable-fields-removed': [ tombstone(ListViewSchema, 'view.list', 'filterableFields'), tombstone(ObjectListViewSchema, 'object.listViews.*', 'filterableFields'), diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 58a14eaad89..bb2c36edee3 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -38,6 +38,7 @@ "src/data/cube-refresh-key-retirement.test.ts", "src/data/currency-mode-family-closure.pin.test.ts", "src/data/document-schemas-retirement.test.ts", + "src/data/driver/turso-encryption-key-retirement.test.ts", "src/data/object-external-sharing-model-retirement.test.ts", "src/identity/position-delegatable-enforcer.pin.test.ts", "src/integration/connector-connection-timeout-retirement.test.ts",