Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/25050-retire-turso-encryption-key.md
Original file line number Diff line number Diff line change
@@ -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

<!-- adr-0087: registered turso-config-encryption-key-removed, turso-config-encryption-key-retired -->

`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.
29 changes: 13 additions & 16 deletions content/docs/data-modeling/drivers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
</Callout>

## Startup: a driver that cannot connect aborts the boot
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/driver-turso.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand Down
3 changes: 1 addition & 2 deletions docs/design/driver-turso.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
Loading
Loading