diff --git a/.changeset/cli-near-miss-statement-trim.md b/.changeset/cli-near-miss-statement-trim.md new file mode 100644 index 000000000..6cd7180aa --- /dev/null +++ b/.changeset/cli-near-miss-statement-trim.md @@ -0,0 +1,15 @@ +--- +'stash': patch +--- + +Trim the leading comment block from near-miss statements reported by the Drizzle +migration rewriter (`stash eql migration --drizzle`, `stash eql install`). + +The broad near-miss scan is anchored on the previous `;`, so a +`SET DATA TYPE … USING …` it could not safely repair was quoted back to the user +with every preceding comment and blank line glued to its front — in a file +opening with a comment block, that meant the whole header. The reported +statement is now the offending statement alone. Detection is unchanged; only the +text shown to the user is affected. + +Keeps this rewriter in sync with its sibling in `@cipherstash/wizard`. diff --git a/.changeset/cli-v2-cutover-prompt-correction.md b/.changeset/cli-v2-cutover-prompt-correction.md new file mode 100644 index 000000000..eb917fe2d --- /dev/null +++ b/.changeset/cli-v2-cutover-prompt-correction.md @@ -0,0 +1,13 @@ +--- +'stash': patch +--- + +`stash init`'s setup prompt no longer tells agents to declare an EQL v2 cutover +column with a `types.*` domain. + +The `types.*` factories are EQL v3 only — a v2 column is an `eql_v2_encrypted` +composite — so an agent following the old step-4 guidance would author a schema +that cannot describe the column it just cut over to. The prompt now says +explicitly not to use `types.*` for a v2 column, and points at the deprecated +`@cipherstash/stack/schema` builders with decryption through +`@cipherstash/stack`, which is the actual read path for legacy v2 rows. diff --git a/.changeset/decrypt-chaining-docs.md b/.changeset/decrypt-chaining-docs.md new file mode 100644 index 000000000..19bcbb8ea --- /dev/null +++ b/.changeset/decrypt-chaining-docs.md @@ -0,0 +1,72 @@ +--- +'stash': patch +'@cipherstash/stack': patch +--- + +Correct the shipped documentation for `decryptModel` / `bulkDecryptModels`. + +Three places in `skills/stash-encryption` and four in `packages/stack/README.md` +said these return "a plain `Promise>` (not a chainable operation)" +and that there is therefore "no `.withLockContext()` to chain". They return an +`AuditableDecryptModelOperation`, which is thenable and carries both +`.withLockContext()` and `.audit()` — the same `.audit()` chain the +audit-on-decrypt work advertises. The skill contradicted itself: its own +reference table already listed the correct return type. + +The skill ships inside the `stash` tarball and `installSkills()` copies it into +customer repos, so this was steering agents away from an API that exists. The +README ships in the `@cipherstash/stack` tarball. + +The equivalent statement about the **WASM entry** is correct and unchanged — +`@cipherstash/stack/wasm-inline` really does return a plain promise from decrypt, +with no lock-context argument. + +Also fixes the setup prompt `stash init` writes for coding agents, which +referenced `protectOps.eq` — an API that does not exist anywhere in the repo. +Every step naming an integration-specific API now branches on the project's +actual integration, instead of naming Drizzle's and Supabase's and leaving the +other two to guess: + +- **Query paths.** `createEncryptionOperators(client)` (conventionally `ops`) + for Drizzle, the `encryptedSupabase` wrapper's own filters for Supabase, the + `eql*` column operators for Prisma Next, and `client.encryptQuery(...)` for a + plain Postgres project. +- **Schema authoring.** The `types.*` column factories for Drizzle, the + `eql_v3_encrypted` domain in migration SQL for Supabase, the `cipherstash.*` + field constructors in `schema.prisma` for Prisma Next, and `encryptedTable` + for plain Postgres. Prisma Next was previously sent at `types.*` / + `encryptedTable` — the client `stash schema build` explicitly refuses to + scaffold for that integration. +- **Read paths.** `decryptModel(row, usersSchema)` where that applies, and the + wrapper's transparent decryption where it does not. +- **Skill pointers.** A plain Postgres project installs no integration-specific + skill, so each "see the integration skill" was a pointer at a file that was + never written. Those now point at `stash-encryption`, which it does get. + +`client.encryptQuery` is also shown taking the schema objects themselves +(`{ table: usersSchema, column: usersSchema.email }`) rather than an +object-shorthand that read as three required strings — `queryType` is inferred +from the column's configured indexes. + +The cutover and complete-rollout **plan templates** now split EQL v3 from v2. +Both described the v2 rename swap (`` → `_plaintext`, twin → ``) +as the only cutover path, so on the default v3 install `stash plan` drafted a +plan built around `stash encrypt cutover` — a command that refuses v3 columns +outright ("there is no rename step") and refuses entirely on a v3-only +database, where `eql_v2_configuration` does not exist. The implement prompt +already carried this split; the plan templates did not. The version is +per-column, so the templates tell the agent to establish it per column rather +than deciding once for the whole plan. + +The "already encrypted" stop-and-ask now recognises `eql_v3_*` domains +alongside the legacy `eql_v2_encrypted` udt, so it can fire on the default +path at all. + +**`stash init` now detects already-encrypted columns on EQL v3.** Database +introspection marked a column as CipherStash-managed only when its udt was +exactly `eql_v2_encrypted`. v3 columns carry per-domain types +(`eql_v3_text_search`, `eql_v3_integer_ord`, …), so on the default path every +encrypted column was reported as plaintext — shown with its `dataType` and left +unticked in the column picker, inviting a re-run to encrypt it a second time. +The picker also labelled any encrypted column with the literal string +`eql_v2_encrypted`; it now shows the column's real domain. diff --git a/.changeset/domain-carrier-not-public.md b/.changeset/domain-carrier-not-public.md new file mode 100644 index 000000000..202284dbb --- /dev/null +++ b/.changeset/domain-carrier-not-public.md @@ -0,0 +1,18 @@ +--- +'@cipherstash/stack': patch +--- + +The phantom domain carrier on encrypted v3 columns is no longer part of the +public surface. + +Recovering a column's domain after declaration emit requires the type parameter +to appear bare somewhere in the instance type, which is what makes typed +`encryptQuery` callable against the published package. That carrier was a +plainly named `__domain` property, and this build has no `stripInternal`, so +`@internal` was documentation rather than enforcement: it appeared in +autocomplete on every column and could be read from consumer code, typed +`D | undefined` while being `undefined` at runtime in every case. + +It is now keyed by a `unique symbol` that is not exported, so it is unnameable +outside the package while remaining exactly as inferrable. Nothing is emitted at +runtime and no call site changes. diff --git a/.changeset/drizzle-encrypted-indexes.md b/.changeset/drizzle-encrypted-indexes.md index d9f390253..9af771af2 100644 --- a/.changeset/drizzle-encrypted-indexes.md +++ b/.changeset/drizzle-encrypted-indexes.md @@ -2,7 +2,7 @@ '@cipherstash/stack-drizzle': minor --- -New `encryptedIndexes` helper on the `/v3` entry: spread +New `encryptedIndexes` helper on the package root: spread `...encryptedIndexes(t)` in `pgTable`'s third-argument callback and it derives the recommended functional indexes for every encrypted column in the table — named `__`, tracked by `drizzle-kit generate` like diff --git a/.changeset/dynamodb-eql-v3.md b/.changeset/dynamodb-eql-v3.md index c6303c997..54b960c92 100644 --- a/.changeset/dynamodb-eql-v3.md +++ b/.changeset/dynamodb-eql-v3.md @@ -11,10 +11,13 @@ Pass a table built with `encryptedTable` + the `types.*` domains from the typed client from `EncryptionV3` and the nominal client from `Encryption({ config: { eqlVersion: 3 } })` are accepted. -EQL v2 tables continue to work unchanged — this is additive, and no existing -caller needs to change. The table decides which wire format is used, so a -DynamoDB table populated under one version must keep being read with that -version. +EQL v2 tables continue to be **readable** — `decryptModel` / +`bulkDecryptModels` still accept one, so existing items stay accessible. Writing +through a v2 table is a separate matter: `encryptModel` / `bulkEncryptModels` +narrowed to EQL v3 tables in this same release, so a caller that still encrypts +through a v2 table does need to change. The table decides which wire format is +used, so a DynamoDB table populated under one version must keep being read with +that version. This fixes a latent bug that made v3 unusable: the write path detected an encrypted value by its `k: 'ct'` tag, but EQL v3 scalars carry no `k` @@ -71,4 +74,4 @@ type, where a declared column `email` becomes `email__source` (plus `email`. `decryptModel` / `bulkDecryptModels` invert it via `DecryptedAttributes`. `AnyEncryptedTable`, `DynamoDBEncryptionClient` and `AuditConfig` are now exported from `@cipherstash/stack/dynamodb` so these signatures can be named. -The EQL v2 overloads are unchanged. +The EQL v2 **decrypt** overloads are unchanged; the v2 encrypt overloads are removed in this release. diff --git a/.changeset/dynamodb-skill-wasm-caveat.md b/.changeset/dynamodb-skill-wasm-caveat.md new file mode 100644 index 000000000..21d3f80ba --- /dev/null +++ b/.changeset/dynamodb-skill-wasm-caveat.md @@ -0,0 +1,17 @@ +--- +'stash': patch +--- + +The `stash-dynamodb` and `stash-encryption` skills documented EQL v2 decrypt as +unconditionally supported, without noting that `@cipherstash/stack/wasm-inline` +is EQL v3 only. Since that is the documented entry for Deno, Bun, Cloudflare +Workers and Supabase Edge Functions — runtimes commonly paired with DynamoDB — a +reader following the skill hit a runtime refusal with no forewarning. Both skills +now state that legacy v2 items are readable on the native `@cipherstash/stack` +entry only. + +The `stash-dynamodb` API reference also claimed audit metadata forwards to +ZeroKMS "regardless of client shape". It does not: the wasm-inline client's +operations return a plain promise with no `.audit()`, so its audit metadata is +dropped (logged at debug). The reference now says so, and says the operation +still succeeds. diff --git a/.changeset/dynamodb-v2-read-table-forwarding.md b/.changeset/dynamodb-v2-read-table-forwarding.md new file mode 100644 index 000000000..82918224a --- /dev/null +++ b/.changeset/dynamodb-v2-read-table-forwarding.md @@ -0,0 +1,27 @@ +--- +'@cipherstash/stack': patch +--- + +Fix `encryptedDynamoDB`'s EQL v2 read path, which failed against a v3-configured +client — the case the v2 read support exists for. + +`decryptModel` and `bulkDecryptModels` forwarded the table to the encryption +client unconditionally. The second argument is only meaningful to the typed EQL +v3 client, which resolves it against its own reconstructor map; a legacy v2 +table is not in that map, so the call failed with: + +``` +[eql/v3]: decryptModel received a table this client was not initialized with +``` + +That contradicted the adapter's documented contract — writes are EQL v3 only, +but decrypt keeps accepting a v2 table so previously stored items stay readable. +In practice the failure hit exactly the customers the compatibility promise was +written for: upgraded to a v3 schema, with v2 items still in the table. + +The table is now forwarded only when it is an EQL v3 table. A v2 table takes the +table-less form, and the client derives the table from the payloads themselves. + +Both paths are covered by a credential-free test asserting what the adapter +forwards, so a regression no longer depends on a live-credential integration run +to surface. diff --git a/.changeset/dynamodb-wasm-v2-read.md b/.changeset/dynamodb-wasm-v2-read.md new file mode 100644 index 000000000..b75673051 --- /dev/null +++ b/.changeset/dynamodb-wasm-v2-read.md @@ -0,0 +1,39 @@ +--- +'@cipherstash/stack': patch +--- + +`encryptedDynamoDB` now refuses a `@cipherstash/stack/wasm-inline` client paired +with a legacy EQL v2 table, instead of failing at the first read with a +misleading error. + +The adapter's v2 read path deliberately calls `decryptModel(item)` with **no** +table — a v2 table means nothing to a v3 client's reconstructor map, and the +native clients derive the table from the payloads anyway. `WasmEncryptionClient` +cannot do that: its decrypt requires the table and resolves date fields from a +per-table map, so the omitted argument surfaced as +`TypeError: Cannot read properties of undefined (reading 'tableName')` thrown +from deep inside the client — on the documented entry for Deno, Cloudflare +Workers and Supabase Edge Functions, which satisfies the adapter's client type +structurally and so was accepted with no cast. + +The pairing is now rejected at the call site, with a message naming both the +combination and the fix. The message is operation-neutral: the guard runs on all +four operations, so a plain-JS caller reaching the write path with a v2 table +gets a message that does not claim a read it never attempted. + +`encryptModel` / `bulkEncryptModels` now also tolerate a client whose encrypt +returns a plain promise. They chained `.audit()` onto the result +unconditionally, which the wasm-inline client does not carry — so **every EQL v3 +write through this adapter on that entry** failed with +`client.encryptModel(...).audit is not a function`, surfaced as a +`DYNAMODB_ENCRYPTION_ERROR` and so indistinguishable from a real encryption +fault. The read path already handled this; the write path now matches, via the +same fail-closed check that rejects a malformed result rather than passing an +unencrypted item through as a success. Audit metadata still has nowhere to go on +that client, so it is dropped and logged at debug — the write itself succeeds. + +Three comments in this package claimed audit metadata was forwarded "regardless +of client shape" and that "every client this package ships carries `.audit()` on +decrypt". Neither was true of the wasm-inline client, whose decrypt returns a +plain promise — the metadata is dropped, observably (it is logged at debug), and +the comments and the debug message now say so. diff --git a/.changeset/encrypt-client-guard-parity.md b/.changeset/encrypt-client-guard-parity.md new file mode 100644 index 000000000..63c3a2664 --- /dev/null +++ b/.changeset/encrypt-client-guard-parity.md @@ -0,0 +1,20 @@ +--- +'stash': patch +--- + +`stash encrypt backfill` now names the cause when the encryption client has no +initialized encrypt config, instead of reporting a missing table. + +The guard that refuses an unusable client file existed twice — once in +`loadEncryptConfig` (`stash db push` / `db validate`) and once, hand-copied, in +`loadEncryptionContext` (`stash encrypt backfill`). The copies had already +drifted: for a client whose `getEncryptConfig()` returns nothing, `db push` +exited 1 with `Encryption client in has no initialized encrypt config`, +while `encrypt backfill` fell through to `Table "users" was not found in the +encryption client exports. Available: (none)` — naming the symptom rather than +the cause, which is precisely the failure the guard was added to eliminate. + +Both loaders now call one shared guard, so a single file cannot produce two +different diagnoses, and the refusals are pinned at both public entry points. +The un-replaced `stash init` scaffold is unaffected — it was already refused by +both, with the same message. diff --git a/.changeset/encrypt-lifecycle-mixed-table.md b/.changeset/encrypt-lifecycle-mixed-table.md new file mode 100644 index 000000000..3b3dab41a --- /dev/null +++ b/.changeset/encrypt-lifecycle-mixed-table.md @@ -0,0 +1,59 @@ +--- +'stash': patch +--- + +`stash encrypt cutover` and `stash encrypt drop` no longer act on — or report +success for — an encrypted column they only guessed at. + +On a table holding both a legacy EQL v2 pair (`ssn` / `ssn_encrypted`) and one +unrelated EQL v3 column, the v2 ciphertext column is not classified as an EQL +column at all, so column resolution fell through to the "this is the table's +only EQL column" rule and claimed the unrelated v3 column. Three consequences, +all now fixed: + +- **`cutover` reported success for work it never did.** Its EQL v3 branch had no + guard on how the column was resolved, and returned without setting an exit + code — so it printed "point your application at `email_enc`" and exited 0 + while the v2 rename never ran. A scripted rollout read that as complete. It + now refuses, and exits 1, exactly as `drop` already did. + +- **The recorded pairing was discarded.** `encrypt backfill` writes the true + `encryptedColumn` to `.cipherstash/migrations.json`, so the answer was already + on disk — but a hint that failed to resolve was dropped entirely and the + re-resolution reached the guess. A hint naming a column that still exists but + is not an EQL v3 column is now reported as what it is (most often a legacy + `eql_v2_encrypted` counterpart) instead of being replaced by a guess. A + genuinely stale hint — one naming a column that is gone — still falls back to + the naming convention as before. + +- **`drop`'s refusal message prescribed the guess.** It told the user to re-run + `backfill --encrypted-column `. Following it recorded the + guess as fact, so the next run resolved "by hint", walked past the refusal, + and passed the coverage check vacuously — an unrelated but legitimately + backfilled column is non-NULL on every row — then generated a live + `DROP COLUMN` on the plaintext and exited 0. The message now asks for the + column that actually encrypts the named one, and says explicitly not to record + the guess. + +The `unresolvedHint` refusal is scoped to tables that actually hold EQL v3 +columns a guess could wrongly claim. A **pure-v2** table has none, so it still +falls through to the EQL v2 lifecycle exactly as before — including when +`encrypt backfill` recorded an `encryptedColumn` for it, which it does for v2 +columns too. + +Two cases DO newly exit 1, both deliberately: + +- Any table with at least one EQL v3 column where the manifest records an + `encryptedColumn` that exists but is not one of them — not only the + v2-pair-plus-one-v3 shape. The recorded pairing is authoritative and + disagrees with every candidate, so guessing past it is the bug. + +- A column whose encrypted counterpart could only be identified **by + elimination** — no recorded `encryptedColumn`, no `_encrypted` name + match, one EQL column left once the plaintext column itself is excluded. + `cutover` now refuses this as `drop` already did. Note `.cipherstash/` is + gitignored, so `migrations.json` is machine-local: a fresh clone or CI runner + can hit this on a **pure-v3** table whose encrypted column is named + unconventionally, where `cutover` previously exited 0 with "not applicable". + Re-run `stash encrypt backfill --table T --column C --encrypted-column ` + to record the pairing. diff --git a/.changeset/encryption-schema-arrays.md b/.changeset/encryption-schema-arrays.md new file mode 100644 index 000000000..f5a125303 --- /dev/null +++ b/.changeset/encryption-schema-arrays.md @@ -0,0 +1,52 @@ +--- +'@cipherstash/stack': minor +'@cipherstash/prisma-next': patch +--- + +`Encryption({ schemas })` now accepts any non-empty array of EQL v3 tables, not +only an array literal. + +Previously the v3 signature required a non-empty *tuple*, so every indirect form +was rejected at compile time even though it worked at runtime: + +```ts +// all of these used to fail with TS2769 +export const schemas: AnyV3Table[] = [users, orders] +await Encryption({ schemas }) + +const readonlySchemas: ReadonlyArray = [users, orders] +await Encryption({ schemas: readonlySchemas }) + +const built: AnyV3Table[] = [] +built.push(users) +await Encryption({ schemas: built }) +``` + +They all compile now. `Encryption({ schemas: [] })` is still a compile error, and +an array literal still gets full per-column typing — passing the wrong plaintext +for a column's domain, or a table the client was not built with, is still +rejected. + +`EncryptionClientFor` is widened to match, so it names the typed client for a +loose `readonly AnyV3Table[]` as well as for a tuple. Code that is generic over +its schemas — an adapter that builds a table per request, say — can now write +`EncryptionClientFor` and keep the typed surface. + +If you narrowed a schema array to `readonly [AnyV3Table, ...AnyV3Table[]]` to +satisfy the old signature, that narrowing is no longer needed. + +A wrapper that is itself generic over its schemas keeps working too: + +```ts +async function makeClient( + schemas: S, +) { + return await Encryption({ schemas }) +} +``` + +Note the non-empty tuple constraint. A wrapper generic over a loose +`readonly AnyV3Table[]` is still rejected — that type admits `readonly []`, so +the wrapper cannot promise what `Encryption` requires. Constrain it as above, or +take `EncryptionClientFor` as a parameter instead of +building the client inside the generic function. diff --git a/.changeset/eql-v3-sole-docs.md b/.changeset/eql-v3-sole-docs.md index 1e1a72020..540d34a1f 100644 --- a/.changeset/eql-v3-sole-docs.md +++ b/.changeset/eql-v3-sole-docs.md @@ -5,12 +5,22 @@ Docs: EQL v3 is now the sole documented approach. The `stash-encryption`, `stash-drizzle`, and `stash-supabase` skills and the `@cipherstash/stack` -README teach only the v3 typed surface (`EncryptionV3`, `types.*` concrete -domains, `@cipherstash/stack-drizzle/v3`, `encryptedSupabaseV3`); EQL v2 -shrinks to one short Legacy section per document. Two explicit exceptions are -called out: DynamoDB still requires the v2 schema surface (#657), and the -encrypt rollout tooling (`stash encrypt backfill`/`cutover`, -`@cipherstash/migrate`) currently targets v2 columns (#648) — its guidance is -kept under a version callout. Also corrects the legacy `@cipherstash/drizzle` -README's pointer to the removed `@cipherstash/stack/drizzle` subpath (now the -separate `@cipherstash/stack-drizzle` package). +README teach only the v3 typed surface (`Encryption`, the `types.*` concrete +domains, the `@cipherstash/stack-drizzle` package root, `encryptedSupabase`); +EQL v2 shrinks to one short Legacy section per document. Two places keep more +than a Legacy section, because EQL v2 is still reachable there: + +- **DynamoDB reads.** `encryptedDynamoDB` writes EQL v3 only, but `decryptModel` + / `bulkDecryptModels` still accept an EQL v2 table so previously stored v2 + items stay readable, so the `stash-dynamodb` skill keeps the v2 schema shape + documented for that read path (#657). +- **The encrypt rollout lifecycle.** `stash encrypt *` and `@cipherstash/migrate` + detect a column's generation from its Postgres domain type, with EQL v3 as the + recognised and default generation; a legacy `eql_v2_encrypted` column does not + classify and falls through to the v2 lifecycle, which ends in + `stash encrypt cutover` rather than the v3 `stash encrypt drop`. That + difference is kept under a version callout (#648). + +Also corrects the legacy `@cipherstash/drizzle` README's pointer to the removed +`@cipherstash/stack/drizzle` subpath (now the separate `@cipherstash/stack-drizzle` +package). diff --git a/.changeset/init-placeholder-eql-v3.md b/.changeset/init-placeholder-eql-v3.md index b82cf31cc..6be0fe42e 100644 --- a/.changeset/init-placeholder-eql-v3.md +++ b/.changeset/init-placeholder-eql-v3.md @@ -17,3 +17,7 @@ the concrete-domain `types.*` factories (`types.TextSearch`, `types.IntegerOrd`, `types.Text`, `types.Json`, …), and the `@cipherstash/stack-drizzle/v3` entry (`extractEncryptionSchemaV3`) for Drizzle. The `encryptionClient` export shape and the empty-schema "no schemas yet" error path are unchanged. + +**Superseded later in this release** by the `@cipherstash/stack-drizzle` EQL v2 +removal: the scaffolds now emit `Encryption` from `@cipherstash/stack/v3` and +`extractEncryptionSchema` from the collapsed `@cipherstash/stack-drizzle` root. diff --git a/.changeset/init-scaffold-compiles.md b/.changeset/init-scaffold-compiles.md new file mode 100644 index 000000000..f20ae5d4d --- /dev/null +++ b/.changeset/init-scaffold-compiles.md @@ -0,0 +1,27 @@ +--- +'stash': patch +--- + +The client file `stash init` writes now compiles. + +Both placeholder templates emitted `await Encryption({ schemas: [] })`, and +`Encryption` requires at least one table — an empty schema set is a deliberate +compile error, so it cannot be relaxed. Every `stash init` therefore left a +project whose first `tsc` or `next build` failed, in a file the CLI had just +told the user not to hand-edit. (The previous scaffold called `EncryptionV3`, +whose looser bound accepted `[]`; collapsing that into an alias of `Encryption` +tightened it.) + +The scaffold now declares a single sentinel table, `__stash_placeholder__`, so +the file typechecks as written. Every command that reads the encryption client +— `stash db push`, `stash db validate`, and `stash encrypt backfill` — refuses +to run while that table is still the only one declared, and names it, rather +than failing later with a confusing "table not found". (`stash encrypt cutover` +and `stash encrypt drop` do not read the client file at all; they resolve +against the database.) + +Nothing in the repo compiled this output before: `packages/cli` has no +typecheck step, the codegen tests only string-match fragments of the template, +and the step test stubs the generator out entirely. Both templates are now +committed as fixtures that CI typechecks, pinned byte-for-byte to the generator +so they cannot drift. diff --git a/.changeset/migrate-column-exists.md b/.changeset/migrate-column-exists.md new file mode 100644 index 000000000..d88867430 --- /dev/null +++ b/.changeset/migrate-column-exists.md @@ -0,0 +1,21 @@ +--- +'@cipherstash/migrate': minor +'stash': patch +--- + +Add `columnExists(client, tableName, columnName)` — a case-exact "does this +column exist at all?" catalog probe, distinct from `detectColumnEqlVersion`'s +"and is it an EQL column?". + +Callers need that difference to tell a STALE column reference (it is gone) from +a live one the domain classifier simply does not recognise — most often a legacy +`eql_v2_encrypted` counterpart. + +`stash encrypt cutover` / `drop` had a private copy of this probe built on a bare +`to_regclass($1)`. That form *parses* its argument and case-folds unquoted +identifiers, so on a Prisma-style `"User"` table it resolved `user`, reported the +column missing, and treated a valid recorded pairing as stale — silently skipping +the fail-closed that stops those commands acting on a guessed encrypted column. +The shared implementation quotes with `format('%I')` first, like every other +catalog probe in this package, so the lookup is case-exact while still honouring +`search_path` for unqualified names. diff --git a/.changeset/one-sided-eql-detection-docs.md b/.changeset/one-sided-eql-detection-docs.md new file mode 100644 index 000000000..0da0531a7 --- /dev/null +++ b/.changeset/one-sided-eql-detection-docs.md @@ -0,0 +1,31 @@ +--- +'stash': patch +'@cipherstash/migrate': patch +'@cipherstash/stack': patch +--- + +Correct shipped documentation that claimed the tooling detects a column's EQL +**v2** generation. It does not, and has not since `classifyEqlDomain` dropped v2: +detection is one-sided — a `public.eql_v3_*` Postgres domain classifies as **v3**, +and anything else (a plaintext column, or a legacy `eql_v2_encrypted` one) +classifies as *unknown* and falls through to the **v2** lifecycle. The v2 path is +reached by fallback, not by detection, and a v2 column records no `eqlVersion` in +`.cipherstash/migrations.json`, so `stash encrypt status` reports no version for +it. + +- `skills/stash-supabase/SKILL.md` said the CLI "still auto-detects a v2 column" + (twice, once inside the "Stay on v2 for now" bullet — exactly the case it got + wrong) and that `stash encrypt drop` picks its target from a version the CLI + "auto-detects". All three now describe the one-sided rule, matching the correct + wording already in the same file's EQL version note. This skill is copied into + customer repos by `stash init`, so the wrong version of it was being installed + as guidance. +- `packages/migrate/README.md` documented `detectColumnEqlVersion(client, table, + column)` as returning `2`, `3`, or `null`. It cannot return `2` — the return + type is now stated as `3` or `null`, with what a `null` means for the caller. + The lifecycle intro no longer presents the v2 ladder as a detection result. +- `packages/stack/README.md`'s Supabase example imported and called + `encryptedSupabaseV3`, the `@deprecated` alias, contradicting the same file's + package table and v3-only note. It now uses `encryptedSupabase`. + +Documentation only — no behaviour change. diff --git a/.changeset/pre.json b/.changeset/pre.json index cdaa6fd7c..b874a8b82 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -11,9 +11,6 @@ "@cipherstash/migrate": "0.2.0", "@cipherstash/nextjs": "4.1.1", "@cipherstash/prisma-next": "0.3.2", - "@cipherstash/protect": "12.0.1", - "@cipherstash/protect-dynamodb": "12.0.1", - "@cipherstash/schema": "3.0.1", "@cipherstash/stack": "0.19.0", "@cipherstash/stack-drizzle": "0.0.0", "@cipherstash/stack-supabase": "0.0.0", @@ -70,7 +67,6 @@ "remove-legacy-drizzle-package", "remove-secrets-leftovers", "rename-db-install-to-eql-install", - "schema-stevec-standard-pin", "skills-eql-v3-accuracy", "skills-identity-docs-refresh", "stack-1-0-0-rc", diff --git a/.changeset/prisma-next-v3-client-config.md b/.changeset/prisma-next-v3-client-config.md new file mode 100644 index 000000000..000bb7d41 --- /dev/null +++ b/.changeset/prisma-next-v3-client-config.md @@ -0,0 +1,32 @@ +--- +'@cipherstash/prisma-next': major +--- + +**Breaking:** `CipherstashFromStackV3Options.encryptionConfig` — the config +passed through to the encryption client by `cipherstashFromStack` — is narrowed +from `ClientConfig` to `V3ClientConfig` (`ClientConfig` without the legacy +`eqlVersion: 2` escape hatch). Forcing EQL v2 no longer type-checks: + +```ts +const cipherstash = await cipherstashFromStack({ + contractJson, + encryptionConfig: { eqlVersion: 2 }, + // ^^^^^^^^^^ error TS2322: Type '2' is not assignable to type '3'. +}) +``` + +The option never did what it looked like it did. This package is EQL v3 only, +and `cipherstashFromStack` always builds from an all-v3 schema set, over which +`eqlVersion: 2` is refused at setup — v2 payloads cannot satisfy the columns' +`eql_v3_*` domains. The field promised a client it could never return. + +**Migration:** drop the `eqlVersion` field. Every other `ClientConfig` option +(`workspaceCrn`, `clientId`, `clientKey`, `accessKey`, `authStrategy`, logging, +…) is unchanged and still accepted. + +To read legacy EQL v2 rows, decrypt through `@cipherstash/stack` rather than +asking this adapter for a v2 client: the decrypt path is generation-agnostic and +reads both v2 and v3 payloads. Use the returned `encryptionClient` — `decrypt(…)` +for a single value, or the no-table `decryptModel(row)` / `bulkDecryptModels(rows)` +form for whole models, which is the supported path for models written before the +v3 upgrade. diff --git a/.changeset/reject-v2-wire-over-v3-schemas.md b/.changeset/reject-v2-wire-over-v3-schemas.md new file mode 100644 index 000000000..a4b90ccfe --- /dev/null +++ b/.changeset/reject-v2-wire-over-v3-schemas.md @@ -0,0 +1,30 @@ +--- +'@cipherstash/stack': minor +--- + +`Encryption({ schemas, config: { eqlVersion: 2 } })` now throws when every table +in `schemas` is an EQL v3 table, instead of building a client that writes EQL v2 +payloads into `eql_v3_*` columns. + +`EncryptionV3` used to prevent this by forcing `eqlVersion: 3` over whatever the +caller passed — the override's comment said that was why it existed. Collapsing +`EncryptionV3` into a deprecated alias of `Encryption` removed the override, and +nothing else rejected the combination: `resolveEqlVersion` already threw for a +mixed v2/v3 schema set and for legacy v2 `searchableJson()`, but returned an +explicit version unchanged. So a caller upgrading from +`EncryptionV3({ schemas: [v3Table], config: { eqlVersion: 2 } })` — previously +auto-corrected, and working — silently got a v2-wire client instead, with no +diagnostic at any layer. The type surface agreed with the runtime (both say +"nominal client"), so nothing disagreed loudly enough to notice, and the failure +surfaced later as an `eql_v3_*` domain CHECK rejecting the write, or as v2 wire +landing in a v3 column wherever the check is looser. + +The escape hatch itself is unchanged where it is actually used: an explicit +`eqlVersion: 2` over an **EQL v2** schema set still emits v2 wire, which is how +v2 payloads are minted for the read-compatibility suite. Mixed sets still throw +the existing mixing error. Reading v2 payloads is unaffected — `decrypt` and +`decryptModel` continue to read both generations regardless of the client's wire +version. + +If you hit the new error, drop `config.eqlVersion` to emit v3, or build the +client from the EQL v2 schema you actually intend to write. diff --git a/.changeset/remove-eql-v2-drizzle-root.md b/.changeset/remove-eql-v2-drizzle-root.md new file mode 100644 index 000000000..64d918746 --- /dev/null +++ b/.changeset/remove-eql-v2-drizzle-root.md @@ -0,0 +1,27 @@ +--- +'@cipherstash/stack-drizzle': major +'@cipherstash/stack': patch +'stash': patch +--- + +Remove the EQL v2 authoring surface from `@cipherstash/stack-drizzle` and collapse the EQL v3 `./v3` subpath into the package root. + +**Breaking (`@cipherstash/stack-drizzle`):** + +- The EQL v2 root exports are gone: `encryptedType`, the v2 `extractEncryptionSchema`, the v2 `createEncryptionOperators` (including the `like` / `ilike` operators), and `EncryptionConfigError`. Authoring or querying `eql_v2_encrypted` columns through Drizzle is no longer supported. +- The `./v3` subpath is **removed** from the package `exports` map and `typesVersions`. The EQL v3 implementation is now the package root, and the `*V3` names are de-suffixed (`createEncryptionOperatorsV3` → `createEncryptionOperators`, `extractEncryptionSchemaV3` → `extractEncryptionSchema`). This is a **hard break with no alias**: post-collapse the root names would collide with the removed v2 names, and keeping an alias would silently type-check v2 call sites against v3 semantics. + +**Migration** — import the v3 surface from the package root instead of `./v3`, and drop the `V3` suffix: + +```diff +- import { types, extractEncryptionSchemaV3, createEncryptionOperatorsV3 } from '@cipherstash/stack-drizzle/v3' ++ import { types, extractEncryptionSchema, createEncryptionOperators } from '@cipherstash/stack-drizzle' +``` + +The `types.*` column factories, `makeEqlV3Column` / `getEqlV3Column` / `isEqlV3Column`, the codec helpers (`v3ToDriver` / `v3FromDriver` / `EqlV3CodecError`), and `EncryptionOperatorError` are unchanged apart from moving to the root. + +Existing EQL v2 ciphertext remains decryptable via `@cipherstash/stack` — only the Drizzle-side v2 authoring and query-building is removed. + +**`stash` (patch):** `stash init --drizzle` scaffolded the removed surface — the generated encryption-client file and the Drizzle placeholder both imported `extractEncryptionSchemaV3` from `@cipherstash/stack-drizzle/v3`, so a freshly-initialised project would not resolve against this release. Both now emit the collapsed root import. The bundled `stash-drizzle` and `stash-encryption` skills are updated to match. + +**`@cipherstash/stack` (patch):** README only — its Drizzle section documented the removed `./v3` subpath and the `*V3` export names. diff --git a/.changeset/remove-eql-v2-migrate-classifier.md b/.changeset/remove-eql-v2-migrate-classifier.md new file mode 100644 index 000000000..e6ae23fb6 --- /dev/null +++ b/.changeset/remove-eql-v2-migrate-classifier.md @@ -0,0 +1,20 @@ +--- +'@cipherstash/migrate': patch +--- + +Drop EQL v2 from the domain-type classifier. `classifyEqlDomain` (and the +`detectColumnEqlVersion` / `listEncryptedColumns` / `resolveEncryptedColumn` +resolution built on it) no longer recognise the legacy `eql_v2_encrypted` +domain — v3 is the sole generation this workspace authors and backfills, so a +column's version is now determined solely from its self-describing `eql_v3_*` +domain type. A legacy v2 column's version is carried by the manifest's recorded +`eqlVersion` instead (the CLI's `encrypt status` / `status` renderers already +fall back to it), so status output is unchanged for v2 columns already recorded +in `.cipherstash/migrations.json`. A v2 column backfilled from here on records +no `eqlVersion` and so reports no version in `stash encrypt status` — the v2 +lifecycle itself (cut-over, then dropping `_plaintext`) is unaffected. + +This removes v2 *classification*, not the v2 read path: existing v2 ciphertext +remains decryptable through `@cipherstash/stack`. `EqlVersion` keeps its `2` +member for manifest-sourced legacy values; the exported function signatures are +unchanged. diff --git a/.changeset/remove-eql-v2-packages.md b/.changeset/remove-eql-v2-packages.md new file mode 100644 index 000000000..3cc56bc0a --- /dev/null +++ b/.changeset/remove-eql-v2-packages.md @@ -0,0 +1,22 @@ +--- +'@cipherstash/stack': patch +'@cipherstash/nextjs': patch +--- + +Remove the EQL v2-only published packages `@cipherstash/protect`, +`@cipherstash/schema`, and `@cipherstash/protect-dynamodb` from the repository +and the release train. They formed a closed dependency chain +(`@cipherstash/protect-dynamodb` → `@cipherstash/protect` → `@cipherstash/schema`) +and are superseded by `@cipherstash/stack`: + +- `@cipherstash/protect` (core encryption) → `@cipherstash/stack`, which now + carries the encryption client directly. +- `@cipherstash/schema` (schema builders) → `@cipherstash/stack/schema`. +- `@cipherstash/protect-dynamodb` (standalone DynamoDB adapter) → + `@cipherstash/stack/dynamodb` (`encryptedDynamoDB`), the maintained + implementation. + +Already-published versions remain installable from npm; the git history +preserves the source for any emergency maintenance. Existing EQL v2 ciphertext +stays decryptable through `@cipherstash/stack` — this removes the v2 authoring +and emission surface, not the read path. diff --git a/.changeset/remove-eql-v2-scaffold-examples-meta.md b/.changeset/remove-eql-v2-scaffold-examples-meta.md new file mode 100644 index 000000000..38714d679 --- /dev/null +++ b/.changeset/remove-eql-v2-scaffold-examples-meta.md @@ -0,0 +1,22 @@ +--- +'stash': patch +'@cipherstash/stack': patch +--- + +De-suffix the v3 client name in generated code and shipped guidance. + +`stash init` scaffolded `import { EncryptionV3 } from '@cipherstash/stack/v3'` +into the client file it writes. `EncryptionV3` is a deprecated alias of +`Encryption`, so new projects were started on the deprecated name. The +scaffold now emits `Encryption`. + +`@cipherstash/stack/v3` now re-exports `Encryption` alongside the deprecated +`EncryptionV3` alias, so a v3 schema and its client come from one import +specifier — the deprecation notice already documented this import, but it did +not resolve. + +Corrects the bundled agent skills and package docs, which described +`encryptedSupabase` as the legacy EQL v2 wrapper. It is the EQL v3 factory; +the v2 wrapper was removed. Also drops the stale "DynamoDB still requires v2" +note from the `@cipherstash/stack` README — DynamoDB writes EQL v3 and reads +existing v2 items. diff --git a/.changeset/remove-eql-v2-supabase-authoring.md b/.changeset/remove-eql-v2-supabase-authoring.md new file mode 100644 index 000000000..ad783dc52 --- /dev/null +++ b/.changeset/remove-eql-v2-supabase-authoring.md @@ -0,0 +1,45 @@ +--- +'@cipherstash/stack-supabase': major +--- + +Remove the EQL v2 authoring surface and de-suffix the v3 API to the canonical +unsuffixed names (part of the EQL v2 removal, #707). + +- **`encryptedSupabase` is now the connect-time-introspecting EQL v3 factory** + (formerly `encryptedSupabaseV3`). `encryptedSupabaseV3` remains as a + type-identical `@deprecated` alias, so existing imports keep working. +- **The legacy v2 `encryptedSupabase({ encryptionClient, supabaseClient })` + wrapper is removed** — with it the two-argument `from(tableName, schema)` form + and the hand-written client-side v2 schema. Its `EncryptedSupabaseConfig` and + the v2 `EncryptedSupabaseInstance`/`EncryptedQueryBuilder` type shapes are gone; + the unsuffixed type names now denote the v3 surface. +- **The `*V3` type exports are de-suffixed** to their canonical names — + `EncryptedSupabaseV3Options` → `EncryptedSupabaseOptions`, + `EncryptedSupabaseV3Instance` → `EncryptedSupabaseInstance`, + `TypedEncryptedSupabaseV3Instance` → `TypedEncryptedSupabaseInstance`, + `EncryptedQueryBuilderV3` → `EncryptedQueryBuilder`, + `EncryptedQueryBuilderV3Untyped` → `EncryptedQueryBuilderUntyped`, + `V3FilterableKeys` → `FilterableKeys`, `V3OrderableKeys` → `OrderableKeys`, and + the rest of the `*V3` key-helper types. Each keeps a type-identical + `@deprecated` `*V3` alias. + +**Reading existing v2 data.** Only the v2 *authoring/emission* surface is removed +— no v2 ciphertext is stranded. Decryption in `@cipherstash/stack` is +generation-agnostic, so EQL v2 payloads still decrypt through the core client +(`decrypt` / `decryptModel`). This adapter, however, is now EQL v3 only and will +not auto-read an `eql_v2_encrypted` column: to read legacy v2 data during +migration, decrypt fetched rows with `@cipherstash/stack` directly, or run a +v2-configured setup alongside the v3 one and route per column. Mixed-generation +handling is a customer-side concern (install both and handle it explicitly), not +adapter auto-detection. + +Internally the v3 query builder (`query-builder-v3.ts`) was folded into the base +`EncryptedQueryBuilderImpl`, which is now natively EQL v3; no runtime behaviour or +wire encoding changed. + +**Migration:** rename `encryptedSupabaseV3` → `encryptedSupabase` (or keep using +the alias). If you still use the v2 `encryptedSupabase({ encryptionClient, +supabaseClient }).from(table, schema)` wrapper, migrate the table to an +`eql_v3_*` column domain and switch to the introspecting factory — +`await encryptedSupabase(supabaseUrl, supabaseKey)` — see the `stash-supabase` +skill and https://cipherstash.com/docs. diff --git a/.changeset/remove-eql-v2-supabase-skill.md b/.changeset/remove-eql-v2-supabase-skill.md new file mode 100644 index 000000000..c4c33dedd --- /dev/null +++ b/.changeset/remove-eql-v2-supabase-skill.md @@ -0,0 +1,11 @@ +--- +'stash': patch +--- + +Update the bundled `stash-supabase` agent skill for the EQL v2 removal (#707): +`encryptedSupabase` is now the connect-time-introspecting EQL v3 factory (with +`encryptedSupabaseV3` kept as a `@deprecated` alias), and the legacy v2 +`encryptedSupabase({ encryptionClient, supabaseClient })` authoring wrapper has +been removed. The skill's examples, exported-type list, and migration/cutover +guidance are corrected accordingly. Skills ship inside the `stash` tarball, so +the stale v2 guidance would otherwise land in a user's project. diff --git a/.changeset/rewriter-never-drops-ciphertext.md b/.changeset/rewriter-never-drops-ciphertext.md new file mode 100644 index 000000000..e32890e4c --- /dev/null +++ b/.changeset/rewriter-never-drops-ciphertext.md @@ -0,0 +1,96 @@ +--- +'@cipherstash/wizard': patch +'stash': patch +--- + +Fix a data-loss bug in the Drizzle migration rewriter: a **commented-out** +`ALTER … SET DATA TYPE` was rewritten into executable SQL. The matcher was +comment-blind and the replacement is multi-line, so the author's `-- ` survived +on the first line only — the `DROP COLUMN` on the next line emitted live and +dropped a populated column. + +A statement is now left exactly as written whenever it is inert — inside a `--` +line comment, inside a `/* … */` block, or inside a single-quoted string +literal, where an `ALTER` is data rather than SQL. (Rewriting one splices +`--> statement-breakpoint` markers *inside* the literal, so splitting the file +the way drizzle's migrator does yields a bare, live `DROP COLUMN` as a chunk of +its own.) Quoting is tokenised properly in the process: a `--` inside a string +no longer opens a comment, an apostrophe inside a quoted identifier such as +`"o'brien_data"` no longer opens a phantom string literal, a doubled `''` or +`""` reads as an escape rather than a delimiter, and an unterminated quote of +either kind makes the rest of the file inert rather than live. + +The sweep also refuses to rewrite a column the migration corpus already gives an +encrypted type, so changing a column's encrypted domain no longer drops a column +full of ciphertext. Skipped statements report why they were left alone. This +recognises the encrypted forms drizzle-kit emits, a domain installed into a +non-`public` schema, and an array of a domain — so a corpus that shows a column +as encrypted in any of those shapes is flagged, not rewritten. + +The sweep is now fail-closed about the columns it does not recognise at all. +Previously a column missing from the corpus index was assumed to be plaintext +and rewritten; absence is not evidence, and the declaration can simply live in a +migration directory the sweep never reads — the wizard ships scanning three of +them and indexes each separately. Such a statement is now reported for review +rather than rewritten, so the ADD+DROP+RENAME no longer drops a column that the +migration corpus itself shows already holds ciphertext. That is a guarantee +about what the corpus says, not about the database: the sweep reasons entirely +from migration files, and a database that has drifted from its migration +history is outside what it can see. `stash encrypt cutover` is the sharpest +example — it renames columns directly in the database and never writes drizzle +SQL, so the corpus can still describe a column as plaintext after cutover has +made it ciphertext; the same is true of any change made by hand via psql or the +Supabase dashboard. If your migration history is squashed, the column's +`CREATE TABLE` lives outside the directory being swept, or the database has +simply drifted from what the migrations describe, you will see the statement +flagged instead of repaired: check the column's current type in the database +and either apply the rewrite by hand on an empty table, or use the staged +`stash encrypt` lifecycle. + +An unreadable migration directory (`EACCES`) is reported rather than silently +treated as empty, and the wizard's `Run the migration now?` prompt defaults to No +whenever the sweep rewrote anything, flagged anything, or could not check a +directory at all — naming the directories that went unchecked, and making no +claim about data destruction for a directory nothing is known about. + +Four further ways the sweep could still reach a ciphertext column are closed: + +- **Chained conversions.** The corpus index read `CREATE TABLE`, `ADD COLUMN` + and `RENAME`, but never the sweep's own target. A directory where an earlier + migration already ran `SET DATA TYPE eql_v2_encrypted` — generated by a stack + version predating this sweep — left the column looking plaintext, so a later + domain change dropped its ciphertext. Conversions are now recorded as the + sweep walks the corpus in order, so the first conversion still applies and + only the ones after it are flagged. +- **Schema qualification.** `"users"` and `"public"."users"` are the same table + — Postgres resolves the unqualified name through `search_path` — but they were + indexed under different keys. drizzle-kit emits unqualified while hand-written + SQL and this sweep's own output are qualified, so a corpus mixing the two hid + an encrypted column from the guard. A non-`public` schema stays distinct. +- **Quote parity.** A `$$ … $$` body containing an odd number of apostrophes, or + an `E'a\'b'` literal, ended a string literal earlier than Postgres does. Every + token after it was then misread — including a commented-out `ALTER`, which + read as live and was rewritten into a live `DROP COLUMN`. Dollar-quoted bodies + are skipped whole and `E''` backslash escapes are honoured. +- **Truncated `CREATE TABLE` bodies.** The body was matched up to the first + `);`, which can sit inside a `--` comment or a string `DEFAULT`. Columns + declared after that point vanished from the index entirely. The closing paren + is now located by skipping candidates that are inside a comment or literal. + +The two copies of this rewriter — one in `stash`, one in `@cipherstash/wizard` — +are now compared by a repo test, so a fix can no longer land in one and silently +miss the other. + +**The wizard now sweeps only drizzle-kit output directories.** It cannot +discover a project's configured `out`, so it tries `drizzle/`, `migrations/` and +`src/db/migrations/` — but the last two are generic names that Knex, +node-pg-migrate, Flyway and hand-rolled psql also use. A project whose drizzle +`out` is `drizzle/` and which also keeps a hand-maintained `migrations/` had +that second directory rewritten into ADD+DROP+RENAME, in a directory the wizard +was never pointed at. The fail-closed rule is no defence there: a real migration +history declares its own columns, so the rewrite proceeds. A candidate is now +swept only if it carries the `meta/_journal.json` drizzle-kit maintains. A +directory that holds `.sql` files but no journal is reported rather than passed +over in silence, so a genuine drizzle output whose `meta/` went missing is +visible instead of looking clean. `stash eql migration` and `stash eql install` +are unaffected — both already take a single explicit `--out`. diff --git a/.changeset/schema-stevec-standard-pin.md b/.changeset/schema-stevec-standard-pin.md deleted file mode 100644 index d96bf3ca6..000000000 --- a/.changeset/schema-stevec-standard-pin.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@cipherstash/schema': patch ---- - -`searchableJson()` now pins the SteVec encoding mode to `standard` explicitly. -protect-ffi 0.29 flipped the library default to `compat` (the EQL v3 -encoding); pinning keeps the v2 wire format byte-stable so existing encrypted -JSON columns stay queryable and comparable. diff --git a/.changeset/skills-encryption-client-naming.md b/.changeset/skills-encryption-client-naming.md new file mode 100644 index 000000000..4960a2212 --- /dev/null +++ b/.changeset/skills-encryption-client-naming.md @@ -0,0 +1,8 @@ +--- +'stash': patch +--- + +`skills/stash-encryption` now documents how to name the client's type +(`EncryptionClientFor`, not `Awaited>`, which +always resolves to the untyped nominal client) and states that `schemas` accepts +any non-empty array of v3 tables rather than only an array literal. diff --git a/.changeset/skills-ship-current-copy.md b/.changeset/skills-ship-current-copy.md new file mode 100644 index 000000000..3aa96a880 --- /dev/null +++ b/.changeset/skills-ship-current-copy.md @@ -0,0 +1,18 @@ +--- +'stash': patch +'@cipherstash/wizard': patch +--- + +Fixed: a change to `skills/` could ship a stale copy of that skill. + +Both CLIs copy the repo-root `skills/` into their bundle at build time +(`dist/skills`), which `stash init` then installs into a customer's +`.claude/skills/` or `.codex/skills/`. That directory sits outside the package, +so it was not part of the build's declared inputs — a skills-only edit did not +invalidate the cached build. Once the build began declaring its output +directory, a cache hit stopped being merely stale and started actively restoring +the previous `dist/skills` over the tree, so an edited skill could be published +with the pre-edit text while the source file on disk was correct and CI green. + +The two builds now declare the skills directory as an input, and a test pins +that coupling so it cannot come undone. diff --git a/.changeset/skills-v3-lifecycle-honesty.md b/.changeset/skills-v3-lifecycle-honesty.md new file mode 100644 index 000000000..5808d215e --- /dev/null +++ b/.changeset/skills-v3-lifecycle-honesty.md @@ -0,0 +1,27 @@ +--- +'stash': patch +--- + +Correct the EQL v2/v3 rollout lifecycle in the bundled `stash-encryption`, +`stash-supabase` and `stash-drizzle` agent skills. Each described the **v2** +lifecycle as the unqualified default even though v3 is the default generation, +so an agent following the prose would run steps that do not apply — and, in one +case, expect the wrong column to be dropped. + +- `stash encrypt drop` was documented as removing `_plaintext`. That is the + **v2** target. On a v3 column there is no `_plaintext`: the command drops + the **original ``**, guarded by a `DO` block that takes `ACCESS EXCLUSIVE` + and re-counts unencrypted rows at apply time, raising instead of dropping if + any remain. Each step in the cutover table is now marked v2-only or v3, and the + drop preconditions (`cut-over` for v2, `backfilled` for v3) are stated. +- "The pending row will be promoted to active by `stash encrypt cutover`" was + false for v3, where cutover short-circuits before touching any configuration. + `stash db activate` is the only promotion path there. +- The CipherStash Proxy call-outs told every reader to run `stash db push`. + `db push`/`db activate` manage `eql_v2_configuration`, which EQL v3 does not + ship — on a v3-only database `db push` reports "Nothing to do." and exits 0, + and `db activate` errors. The call-outs are now scoped to the EQL v2 + Proxy + path. + +Skills ship inside the `stash` tarball and are copied into user projects at +`stash init`, so this guidance was being installed into customer repos. diff --git a/.changeset/stack-audit-on-decrypt.md b/.changeset/stack-audit-on-decrypt.md new file mode 100644 index 000000000..48b32a60e --- /dev/null +++ b/.changeset/stack-audit-on-decrypt.md @@ -0,0 +1,54 @@ +--- +'@cipherstash/stack': major +--- + +The typed EQL v3 client's `decryptModel` / `bulkDecryptModels` are now +audit-chainable. They return a chainable operation (a `MappedDecryptOperation`) +instead of a bare `Promise>`, so you can attach audit metadata and a +lock context before awaiting: + +```typescript +await client + .decryptModel(item, table) + .audit({ metadata: { requestId } }) + .withLockContext({ identityClaim: ["sub"] }) +``` + +Both chaining orders forward the metadata to ZeroKMS, and the `Date` +reconstruction still applies to the successful result. Await-only call sites are +unchanged — the operation is still thenable to the same `Result`. This restores +audited decrypt through the DynamoDB adapter (`encryptedDynamoDB(...).decryptModel`) +for a v3 client, which previously had nowhere to carry the metadata. + +Chaining `.withLockContext()` onto a decrypt operation that already took a lock +context positionally (`decryptModel(item, table, lc).withLockContext(other)`) now +throws instead of silently keeping the first. Pass the lock context one way or +the other, not both. + +**Breaking:** `EncryptionV3` is now a deprecated, type-identical alias of +`Encryption`: `Encryption` is overloaded so an array of concrete EQL v3 tables +yields the same strongly-typed client. Use `Encryption` for new code. If you were +already passing EQL v3 tables to plain `Encryption`, you now receive the typed +client rather than the nominal one — its `decryptModel` / `bulkDecryptModels` +return type changes, and the two-argument form reconstructs `Date` columns from +`cast_as` instead of leaving them as ISO strings. Code that read those columns as +strings needs updating. + +The v3 overload takes a `V3ClientConfig` — `ClientConfig` without the deprecated +`eqlVersion` escape hatch. So `Encryption({ schemas: [] })` no longer type-checks +(it used to compile and then throw), and `config: { eqlVersion: 2 }` selects the +nominal overload — though over an all-v3 schema set that call now throws at setup. +`Awaited>` names the nominal client whatever you +pass, because `ReturnType` reads the last overload; use the exported +`EncryptionClientFor` to name the client for a schema tuple. + +`decryptModel` / `bulkDecryptModels` on the typed client also accept a call with +no table, matching the runtime, which has always allowed it — that is the path +for reading models written before the upgrade, above all legacy EQL v2 ones, +whose table cannot be a member of a v3 schema tuple. Prefer the two-argument +form whenever the table is registered. + +The `eqlVersion` config field and the `@cipherstash/stack/schema` EQL v2 +builders remain available (now marked `@deprecated`) for reading and migrating +legacy v2 data; the client authors EQL v3 only. Their full removal is deferred to +a later PR. diff --git a/.changeset/stack-dynamodb-v2-write-removal.md b/.changeset/stack-dynamodb-v2-write-removal.md new file mode 100644 index 000000000..445bd3729 --- /dev/null +++ b/.changeset/stack-dynamodb-v2-write-removal.md @@ -0,0 +1,18 @@ +--- +'@cipherstash/stack': major +--- + +**Breaking (DynamoDB adapter):** `encryptedDynamoDB(...).encryptModel` and +`bulkEncryptModels` no longer accept an EQL v2 table. The v2 write type overloads +have been removed, narrowing encrypt to `AnyV3Table`. The narrowing is +type-level — treat the type as the contract, not a runtime guard. + +**Decrypt still reads existing v2 items.** `decryptModel` / `bulkDecryptModels` +continue to accept an EQL v2 table (`encryptedColumn` / `encryptedField` from +`@cipherstash/stack/schema`), so previously stored v2 DynamoDB items remain +readable — the adapter keeps its v2 envelope reconstruction. Only the v2 write +surface is gone. + +Migrate v2 write call sites to an EQL v3 table (`encryptedTable` + `types.*` from +`@cipherstash/stack/eql/v3`). To keep reading old data, pass the v2 table to the +decrypt methods. diff --git a/.changeset/stack-logger-edge-safe.md b/.changeset/stack-logger-edge-safe.md new file mode 100644 index 000000000..c84c0d97e --- /dev/null +++ b/.changeset/stack-logger-edge-safe.md @@ -0,0 +1,11 @@ +--- +'@cipherstash/stack': patch +--- + +`@cipherstash/stack/adapter-kit` is now importable in a runtime with no `process` global. + +The shared logger read `process.env.STASH_STACK_LOG` unguarded while initialising at module scope, so importing adapter-kit — which re-exports that logger — threw `ReferenceError: process is not defined` before any user code ran. The environment read is now guarded. + +This is not a single-adapter fix. `@cipherstash/stack-supabase`, `@cipherstash/stack-drizzle` and `@cipherstash/prisma-next` all value-import `@cipherstash/stack/adapter-kit`, so edge users of all three hit the same import-time throw, and all three are fixed by this release. + +**No behaviour change on Node.** `STASH_STACK_LOG`, its accepted values (`debug` / `info` / `error`), its `error` default, and the point at which the logger is configured are all unchanged. diff --git a/.changeset/stack-skills-eql-v3-audit.md b/.changeset/stack-skills-eql-v3-audit.md new file mode 100644 index 000000000..f63f9b085 --- /dev/null +++ b/.changeset/stack-skills-eql-v3-audit.md @@ -0,0 +1,13 @@ +--- +'stash': patch +--- + +Skills refresh for the EQL v3 collapse (ships in the `stash` tarball): + +- `stash-dynamodb`: audited decrypt now works on the typed client — + `client.decryptModel(item, table).audit({ … })` — so the old "use + `Encryption({ config: { eqlVersion: 3 } })` for audited decrypts" caveat is + removed. Encrypt/write is EQL v3 only; decrypt still reads existing v2 items. +- `stash-encryption`: canonical examples use `Encryption` (with `EncryptionV3` + noted as a deprecated alias); the DynamoDB notes state encrypt is v3-only while + decrypt still reads v2. diff --git a/.changeset/stash-postgres-edge-skills.md b/.changeset/stash-postgres-edge-skills.md new file mode 100644 index 000000000..424c2774b --- /dev/null +++ b/.changeset/stash-postgres-edge-skills.md @@ -0,0 +1,75 @@ +--- +'stash': minor +'@cipherstash/wizard': minor +'@cipherstash/stack': patch +--- + +Two new bundled agent skills for the integrations that don't use an ORM — +`stash-postgres` and `stash-edge` (#754). + +Everything a raw-SQL or edge integration needed was reachable only from +`dist/*.d.ts` JSDoc, the Postgres catalog, or experiment: grepping the skills +`stash init` installs for `postgres-js|::jsonb::eql|sql.json|query_text_search` +returned a single hit, in an unrelated code comment. + +**`stash-postgres`** — hand-written SQL over `pg` / `postgres-js`, no ORM. The +column-domain-to-query-domain operator matrix (which of `=`, `<>`, `<`, `>=`, +`@@`, `@>` each encrypted domain accepts, and against which `eql_v3.query_*` +operand), the storage-vs-query payload distinction, per-driver parameter +binding, recipes for equality / free-text / range / `ORDER BY` / JSON +containment / JSON field selectors, and the `information_schema` drift check. +Two failure modes get their mechanism spelled out: pre-stringifying a payload +on postgres-js double-encodes it into a jsonb *string* scalar, tripping the +domain CHECK with a message naming neither JSON nor encoding; and leaving an +operand as bare `jsonb` silently selects a different operator overload — one +that coerces to the *storage* domain and so rejects the ciphertext-free query +term. It also scopes itself against the two things a hand-written-SQL reader +is otherwise left to infer: **CipherStash Proxy** (where you write plaintext +SQL and none of the skill applies — the `usesProxy` fork `stash init` already +asked about), and the provenance of the operator surface itself (the EQL +bundle from `cipherstash/encrypt-query-language`, version-checkable with +`SELECT eql_v3.version()`, and where operator gaps should be filed). Its +domain and operator tables are explicitly marked as a snapshot of a versioned +surface, with a ranked list of authorities to confirm current types against — +the EQL skill first, then the generated `@cipherstash/eql` types and install +SQL, both of which need only `node_modules` and no database. + +**`stash-edge`** — the `@cipherstash/stack/wasm-inline` entry for Deno, +Supabase Edge Functions, Cloudflare Workers, and Bun. Import specifier per +runtime, the four mandatory `CS_*` variables and minting them with +`stash env`, how the WASM client surface differs from the native typed client +(no `.audit()`, no `.withLockContext()`, per-item bulk shape, a required +`table` argument on `decryptModel` / `bulkDecryptModels`, ESM-only), and the +auth-strategy `Result` that must be unwrapped before it reaches +`config.authStrategy`. + +It also separates the two mechanisms behind identity-bound encryption, which +are routinely conflated — and which the source comment on the entry itself got +wrong. An auth strategy decides *who the client is*; a lock context decides +*which key the value is encrypted under*. Only the first exists on this entry, +so an `authStrategy` alone still writes values encrypted under the workspace +key, and the entry cannot read what the native one wrote under a lock context. +That is a silent read split between the two entries, and the skill says so +rather than leaving it to be discovered as a failed decrypt. + +Both carry **the credential-identity rule**, a silent data-loss footgun now +also stated in `stash-cli` (under `env` and `encrypt backfill`) and +`stash-supabase`: EQL index terms derive from the ZeroKMS client key, so rows +written under one credential and queried under another decrypt correctly and +never match a query, with no error. + +`stash-encryption` now states that the two entries' schema types **do not +interchange** — their column classes carry private fields, so TypeScript +compares them nominally and rejects a shared schema module in both directions. +It works at runtime, which makes a type assertion the tempting fix; the +guidance is to author the schema against exactly one entry instead. + +`stash init` / `stash impl` handoffs and the `@cipherstash/wizard` skills +prompt install both skills for the `postgresql` and `supabase` integrations. +Drizzle and Prisma Next get cross-links from their own skills instead, since +those integrations emit correctly-typed operands themselves. + +Also fixes the `@cipherstash/stack/wasm-inline` module JSDoc, which showed +`OidcFederationStrategy.create(...)`'s `Result` being passed straight to +`config.authStrategy` without unwrapping — the same JSDoc the raw-SQL surface +was being reverse-engineered from. diff --git a/.changeset/supabase-interface-row-types.md b/.changeset/supabase-interface-row-types.md new file mode 100644 index 000000000..688927d2c --- /dev/null +++ b/.changeset/supabase-interface-row-types.md @@ -0,0 +1,30 @@ +--- +'@cipherstash/stack-supabase': minor +--- + +Row-type generics now accept an `interface`, not just a `type` alias. + +`from()`, `returns()` and `single().returns()` constrained their row +parameter to `Record`. An `interface` has no implicit index +signature, so the most ordinary way to declare a row type failed to compile: + +```typescript +interface User { id: string; email: string } + +// before: TS2344 — Index signature for type 'string' is missing in type 'User' +// after: fine +const { data } = await supabase.from('users').select('id, email') +``` + +A `type User = { … }` alias worked, which is why the existing type tests never +caught it. The constraint is now `object`, which still rejects `string`/`number` +row types. upstream `postgrest-js` constrains `returns` to nothing at all, so +this brings the adapter in line with the API it mirrors rather than being +stricter than it. + +Also corrects the `EncryptedSingleQueryBuilder` documentation, which claimed +that "everything that only re-types or re-configures the pending request is +carried over" after `single()`/`maybeSingle()`. `overrideTypes` and `setHeader` +are not carried over — they have no adapter equivalent, and since +`single()`/`maybeSingle()` return the same builder instance rather than a +passthrough, calling them would fail at runtime, not just fail to typecheck. diff --git a/.changeset/supabase-single-row-typing.md b/.changeset/supabase-single-row-typing.md new file mode 100644 index 000000000..7df5932fe --- /dev/null +++ b/.changeset/supabase-single-row-typing.md @@ -0,0 +1,38 @@ +--- +'@cipherstash/stack-supabase': major +--- + +`single()` and `maybeSingle()` now type `data` as the ROW, not an array. + +Both have always returned one object at runtime, but the builder kept +advertising the array shape it was created with, so `data` was typed `T[] | null` +while holding a single row. Every caller had to launder it: + +```typescript +const { data } = await supabase.from('users').select('id, email').single() +// before: data is `User[] | null` — wrong; a cast was the only way through +const user = data as unknown as User +// after: data is `User | null` +data?.email +``` + +`single()`/`maybeSingle()` now return `EncryptedSingleQueryBuilder`, which +awaits to `EncryptedSupabaseResponse` (`data: T | null`). That covers the +zero-row case for `maybeSingle()` and the error case for both, so no separate +null modelling was needed. + +Filters and transforms are no longer chainable after `single()`/`maybeSingle()`, +matching supabase-js — applying one afterwards would change the query the +single-row promise was made about. `.single().eq(...)`, `.single().limit(...)` +and friends were previously accepted and are now compile errors. What only +re-types or re-configures the pending request is carried over: `returns()` +(preserving the awaited shape, so `.single().returns()` awaits one row), +`abortSignal()`, `throwOnError()`, `withLockContext()` and `audit()`. +`EncryptedSingleQueryBuilder` is exported so a stored builder can be +annotated. + +**Migration:** delete the cast. Code that worked around the old typing with +`data as unknown as Row` (or read `data![0]`) should now use `data` directly; +the cast still compiles but is no longer needed, and `data![0]` becomes a type +error. Move any filter or transform chained after `single()`/`maybeSingle()` to +before it. diff --git a/.changeset/supabase-structural-v3-columns.md b/.changeset/supabase-structural-v3-columns.md new file mode 100644 index 000000000..27d017118 --- /dev/null +++ b/.changeset/supabase-structural-v3-columns.md @@ -0,0 +1,11 @@ +--- +'@cipherstash/stack-supabase': patch +--- + +Fix: a table authored with `encryptedTable`/`types` imported from `@cipherstash/stack/wasm-inline` was treated as having **no encrypted columns**, so filter operands were sent to PostgREST as plaintext. + +`ColumnMap` gated on `builder instanceof EncryptedV3Column`, and the published bundles contain two separately-emitted copies of that class (`dist/adapter-kit.js` and `dist/wasm-inline.js` are separate esbuild runs). The check is now structural, so both copies are recognised. Tables authored from `@cipherstash/stack/eql/v3` were never affected — they resolve to the same copy the adapter imports. + +The failure was silent: `::jsonb` casts and result decryption go through a different path and kept working. + +The recognition now also fails closed: a column builder that does not present the v3 surface makes `encryptedSupabase` throw at construction rather than silently omitting the column — an omitted column would send its filter operands to PostgREST as plaintext. diff --git a/.changeset/supabase-v2-table-diagnosis.md b/.changeset/supabase-v2-table-diagnosis.md new file mode 100644 index 000000000..1c6cfb160 --- /dev/null +++ b/.changeset/supabase-v2-table-diagnosis.md @@ -0,0 +1,25 @@ +--- +'@cipherstash/stack-supabase': patch +'@cipherstash/stack': minor +'stash': patch +--- + +Diagnose an EQL v2 table by name instead of crashing with a raw `TypeError`. + +A v2 `EncryptedTable` is structurally identical to a v3 one — same `tableName`, +same `columnBuilders` — and only `buildColumnKeyMap()` tells them apart. Passing +one to `encryptedSupabase({ schemas })` therefore sailed past every check that +looked at shape and died deep inside verification as `builder.getEqlType is not +a function`, naming an internal method rather than the version mismatch that +caused it. Constructing the query builder directly failed the same way one layer +down, as `table.buildColumnKeyMap is not a function`. + +Both paths now fail closed with the table named and the fix stated. The check +routes through `hasBuildColumnKeyMap`, the canonical v2/v3 discriminator, rather +than a second hand-written spelling of it. + +`@cipherstash/stack` re-exports `hasBuildColumnKeyMap` from +`@cipherstash/stack/adapter-kit` so first-party adapters can make that routing +decision without reaching into internals. It is deliberately not on `./types`: +deciding which wire version a table targets is adapter plumbing, not end-user +API. diff --git a/.changeset/typecheck-gates-scope-source.md b/.changeset/typecheck-gates-scope-source.md new file mode 100644 index 000000000..33879a9b5 --- /dev/null +++ b/.changeset/typecheck-gates-scope-source.md @@ -0,0 +1,20 @@ +--- +'@cipherstash/stack': patch +--- + +The declaration gate now covers all three emitted type artifacts, not just one. + +`packages/stack/dist-types` typechecks what `tsc` emits rather than what the +source says — the gap that let typed `encryptQuery` ship uncallable for every +column through the whole rc series. It resolved `../dist/*.js` by relative path +under bundler resolution, which never consults the `exports` map, so it only ever +exercised the ESM `.d.ts` set. `tsup` emits the CJS `.d.cts` set in a separate +pass and `wasm-inline.d.ts` in a third, each with its own inlined copy of the +column class whose domain parameter the bug was about. + +A second suite now resolves `@cipherstash/stack/*` **by package name** under +`moduleResolution: node16`, with the file extension selecting the condition +(`.cts` → `require` → `.d.cts`, `.mts` → `import` → `.d.ts`), plus a probe for +the `wasm-inline` entry — the documented target for Workers, Deno, Bun and +Supabase Edge, and previously the one artifact nothing typechecked. Removing the +phantom domain carrier makes all three fail, so none of them passes vacuously. diff --git a/.changeset/typed-client-init-parity.md b/.changeset/typed-client-init-parity.md new file mode 100644 index 000000000..ecc41b3b5 --- /dev/null +++ b/.changeset/typed-client-init-parity.md @@ -0,0 +1,37 @@ +--- +'@cipherstash/stack': patch +--- + +The typed EQL v3 client no longer throws `TypeError: init is not a function`. + +`Encryption` selects its overload from the `config` argument but selects the +client it actually returns by inspecting the `schemas`, so the two can disagree. +Hoisting a config into a `ClientConfig`-typed variable is enough to split them — +`ClientConfig.eqlVersion` is `2 | 3`, which the v3 overload's `eqlVersion?: 3` +rejects — so the call types as the nominal `EncryptionClient` while the runtime +still returns the typed client: + +```ts +const config: ClientConfig = { keyset } +const client = await Encryption({ schemas: [users], config }) +// type: EncryptionClient · runtime: TypedEncryptionClient +``` + +`init` was the only member of `EncryptionClient` missing from the typed client, +so any call through the declared type crashed. It is now present — and pins the +wire format rather than delegating verbatim. + +That distinction matters. `EncryptionClient.init` takes its own `eqlVersion` and +forwards `undefined` to the FFI, whose default is EQL **v2**, and it never +consults the check `Encryption` runs at construction. A bare passthrough would +therefore have let a typed v3 client be re-initialised into v2 wire while keeping +its v3 surface — writing `eql_v2_encrypted` payloads into `eql_v3_*` columns, the +same contradiction refused at construction, one method call later. The typed +`init` pins `eqlVersion: 3`, refuses an explicit `2` with a failure `Result`, and +resolves to the **typed** client, so the common +`client = (await client.init(cfg)).data` does not silently swap the typed surface +for the nominal one. + +The type still under-reports in this case: you lose the typed surface with no +diagnostic. Pass the config inline, or type the variable as `V3ClientConfig`, to +keep it. diff --git a/.changeset/typed-encrypt-query-dist.md b/.changeset/typed-encrypt-query-dist.md new file mode 100644 index 000000000..1fce605e7 --- /dev/null +++ b/.changeset/typed-encrypt-query-dist.md @@ -0,0 +1,34 @@ +--- +'@cipherstash/stack': patch +--- + +Fixed: `encryptQuery` on the typed EQL v3 client did not typecheck against the +published package — for any column. + +```ts +const users = encryptedTable('users', { email: types.TextSearch('email') }) +const client = await Encryption({ schemas: [users] }) + +client.encrypt('a@b.com', { table: users, column: users.email }) // fine +client.encryptQuery('a@b.com', { table: users, column: users.email }) +// TS2345: Argument of type '"a@b.com"' is not assignable to parameter of type 'never' +``` + +`PlaintextForColumn` and `QueryTypesForColumn` recover a column's domain with +`C extends EncryptedV3Column`, which needs `D` to appear bare somewhere +in the instance type. Its only bare occurrence was a **private** field, and +`tsc` strips the types of private members on declaration emit — so in the +shipped `.d.ts` the inference fell back to the `V3DomainDefinition` constraint. +`QueryTypesForColumn` collapsed to `never`, which made `QueryableColumnsOf` +`never`, which typed every query plaintext `never`. `encrypt` was unaffected +because it resolves through `ColumnsOf`. + +`EncryptedV3Column` now carries a type-only `declare readonly __domain?: D`. +Nothing is emitted at runtime and no call site changes; the declaration survives +emit and restores the inference site. + +This affected every published release with the v3 typed client, including +`1.0.0-rc.4` — the searchable-query recipes in the `stash-encryption` skill did +not compile in a customer's repo. It was invisible in CI because the type tests +import source rather than `dist/`; a new `test:types:dist` suite now typechecks +the emitted declarations and is wired into CI. diff --git a/.changeset/wizard-eql-v3-migration-rewrite.md b/.changeset/wizard-eql-v3-migration-rewrite.md new file mode 100644 index 000000000..a627e5eb9 --- /dev/null +++ b/.changeset/wizard-eql-v3-migration-rewrite.md @@ -0,0 +1,36 @@ +--- +'@cipherstash/wizard': minor +--- + +Teach the wizard's post-agent Drizzle step to repair EQL **v3** migrations, not +just legacy EQL v2. + +The wizard now scaffolds EQL v3 columns, so `drizzle-kit generate` emits +`ALTER TABLE … ALTER COLUMN … SET DATA TYPE eql_v3_` — which Postgres +rejects (there is no cast from `text`/`numeric` to an EQL domain). The migration +rewriter previously matched only the single `eql_v2_encrypted` type, so those v3 +statements slipped through unrepaired and failed at migrate time. + +The rewriter is ported to match the whole EQL v3 concrete-domain family +(`eql_v3_text_search`, `eql_v3_integer_ord`, …) alongside legacy +`eql_v2_encrypted`, across every mangled form drizzle-kit emits (including the +`"undefined".` prefix from 0.31.0+ and schema-qualified `pgSchema()` tables). It +now also flags near-miss `SET DATA TYPE … USING …` statements it cannot safely +repair instead of leaving broken SQL, and each rewritten file carries a clearer +warning that the ADD+DROP+RENAME is data-destroying and safe only on an empty +table — a populated table must use the staged `stash encrypt` flow. This +re-converges the rewriter with the sibling copy in the `stash` CLI. + +The post-agent step now sweeps **every** candidate migration directory +(`drizzle/`, `migrations/`, `src/db/migrations/`) rather than stopping at the +first one that exists. Previously an empty or already-rewritten `drizzle/` +sitting next to a project's real `migrations/` caused those migrations to be +skipped entirely, so they still failed at migrate time. A directory that can't +be read is reported and the remaining candidates are still swept. Reported +near-miss statements are also trimmed of any preceding comment block, so the +statement quoted back to the user is the offending statement alone. + +Database introspection also recognises v3 encrypted columns: `isEqlEncrypted` +now reports both `eql_v2_encrypted` and the `eql_v3_*` family as already +encrypted, so the agent won't scaffold over existing encrypted data of either +generation. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5e475458e..3bcb587ca 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -48,9 +48,11 @@ updates: - dependency-name: "@cipherstash/auth-*" # Release-managed manually alongside stack releases. Grouped bumps are # actively harmful here: Dependabot's "group consistency" once upgraded - # the sunsetting packages/protect off its 0.23.0 pin (0.24+ renames the - # exports it imports) while DOWNGRADING the stack packages from 0.29.0 - # (see #673). + # the (since-removed) `protect` package off its 0.23.0 pin — 0.24+ renamed + # the exports it imported — while DOWNGRADING the stack packages from + # 0.29.0 (see #673). The surviving consumers (stack, stack-drizzle, + # stack-supabase) pin one version between them and must stay in lockstep, + # so the rule stands. - dependency-name: "@cipherstash/protect-ffi" # 0.x bumps ship breaking type changes (e.g. 0.2 → 0.3 tightened the # FailureOption constraint). Review and apply manually. diff --git a/.github/workflows/fta-v3.yml b/.github/workflows/fta-v3.yml index 80e494fec..dd3108b43 100644 --- a/.github/workflows/fta-v3.yml +++ b/.github/workflows/fta-v3.yml @@ -68,10 +68,19 @@ jobs: # blocking gate. No `continue-on-error`. # One step per package so a failure names the offending package. Each caps # at its current worst file (a ratchet, not an aspiration): stack src/eql/v3 - # at 69, and the split adapter packages at their monolith maxima — drizzle - # 89 (operators.ts), supabase 91 (query-builder.ts). Lower a cap whenever a - # file is refactored below the next threshold; the v2 query-builder/operators - # monoliths are the debt to whittle down toward stack's 69. + # at 69, drizzle 89 (operators.ts, still a monolith), and supabase 71 + # (query-encrypt.ts at 70.12, after the query-builder monolith was split + # across column-map / query-encrypt / query-mutation / query-dbspace / + # query-filters / query-results). Lower a cap whenever a file is refactored + # below the next threshold; drizzle's operators.ts is the remaining debt to + # whittle down toward stack's 69. + # + # NB supabase's top three now cluster tightly (query-encrypt 70.12, + # query-builder 70.05, helpers 69.13), so 71 is a ~0.9 margin. A cap set + # flush against its max is fragile — the 91 this replaced was 0.12 above + # its max and a single refactor blew straight through it. If a formatting + # reflow trips this step without a real complexity increase, re-measure + # (`npx fta src --format table`) before assuming the code got worse. - name: Analyze stack (eql/v3) complexity run: pnpm --filter @cipherstash/stack run analyze:complexity diff --git a/.github/workflows/integration-drizzle.yml b/.github/workflows/integration-drizzle.yml index ef8a42adc..c4b3e7669 100644 --- a/.github/workflows/integration-drizzle.yml +++ b/.github/workflows/integration-drizzle.yml @@ -23,7 +23,6 @@ on: # suite that would catch it. - 'packages/stack/src/encryption/**' - 'packages/stack/src/schema/**' - - 'packages/schema/**' - 'packages/stack/integration/**' # The WASM family suite (integration/wasm/**) exercises this entry: - 'packages/stack/src/wasm-inline.ts' @@ -46,7 +45,6 @@ on: # suite that would catch it. - 'packages/stack/src/encryption/**' - 'packages/stack/src/schema/**' - - 'packages/schema/**' - 'packages/stack/integration/**' # The WASM family suite (integration/wasm/**) exercises this entry: - 'packages/stack/src/wasm-inline.ts' @@ -155,8 +153,14 @@ jobs: # # Step env, not a `.env` file: `dotenv/config` does not override an # already-set `process.env`, so these win and no secret is written to disk. + # Through turbo, not a bare `pnpm --filter`: `test:integration` declares + # `dependsOn: ["^build", "build"]`, and the only build in this job is the + # setup action's `--filter stash`, which reaches these packages purely by + # coincidence of stash's own dependency graph. `--env-mode=loose` because + # turbo defaults to strict and would otherwise withhold the step env below + # from the test process (#787 review follow-up). - name: Drizzle v3 integration suites - run: pnpm --filter @cipherstash/stack-drizzle run test:integration + run: pnpm exec turbo run test:integration --filter @cipherstash/stack-drizzle --env-mode=loose env: DATABASE_URL: ${{ steps.db.outputs.database-url }} PGRST_URL: ${{ steps.db.outputs.pgrest-url }} @@ -166,7 +170,7 @@ jobs: # `isInstalled` short-circuits against the DB the first invocation already # provisioned — a fast no-op check, not a second schema apply. - name: Shared core + identity + wasm integration suites - run: pnpm --filter @cipherstash/stack run test:integration + run: pnpm exec turbo run test:integration --filter @cipherstash/stack --env-mode=loose env: DATABASE_URL: ${{ steps.db.outputs.database-url }} PGRST_URL: ${{ steps.db.outputs.pgrest-url }} diff --git a/.github/workflows/integration-prisma-next.yml b/.github/workflows/integration-prisma-next.yml index f3b128e01..6b7e19134 100644 --- a/.github/workflows/integration-prisma-next.yml +++ b/.github/workflows/integration-prisma-next.yml @@ -25,7 +25,6 @@ on: # suite that would catch it. - 'packages/stack/src/encryption/**' - 'packages/stack/src/schema/**' - - 'packages/schema/**' - 'packages/test-kit/**' - 'packages/cli/src/installer/**' - 'local/docker-compose.postgres.yml' @@ -45,7 +44,6 @@ on: # suite that would catch it. - 'packages/stack/src/encryption/**' - 'packages/stack/src/schema/**' - - 'packages/schema/**' - 'packages/test-kit/**' - 'packages/cli/src/installer/**' - 'local/docker-compose.postgres.yml' @@ -116,7 +114,7 @@ jobs: # `stash eql install --eql-version 3`, so an installer regression fails # here rather than hiding behind a test-only SQL apply. - name: prisma-next v3 family suites - run: pnpm --filter @cipherstash/prisma-next run test:integration + run: pnpm exec turbo run test:integration --filter @cipherstash/prisma-next --env-mode=loose env: # Step env, not a `.env` file: `dotenv/config` does not override an # already-set `process.env`, so these win and no secret hits disk. diff --git a/.github/workflows/integration-supabase.yml b/.github/workflows/integration-supabase.yml index 7ab83ca46..f5eff3093 100644 --- a/.github/workflows/integration-supabase.yml +++ b/.github/workflows/integration-supabase.yml @@ -19,7 +19,6 @@ on: # trigger the live suite that would catch it. - 'packages/stack/src/encryption/**' - 'packages/stack/src/schema/**' - - 'packages/schema/**' - 'packages/stack/integration/**' - 'packages/test-kit/**' - 'packages/cli/src/installer/**' @@ -38,7 +37,6 @@ on: # trigger the live suite that would catch it. - 'packages/stack/src/encryption/**' - 'packages/stack/src/schema/**' - - 'packages/schema/**' - 'packages/stack/integration/**' - 'packages/test-kit/**' - 'packages/cli/src/installer/**' @@ -131,7 +129,7 @@ jobs: # Step env, not a `.env` file: `dotenv/config` does not override an # already-set `process.env`, so these win and no secret is written to disk. - name: Supabase v3 integration suites - run: pnpm --filter @cipherstash/stack-supabase run test:integration + run: pnpm exec turbo run test:integration --filter @cipherstash/stack-supabase --env-mode=loose env: DATABASE_URL: ${{ steps.db.outputs.database-url }} PGRST_URL: ${{ steps.db.outputs.pgrest-url }} @@ -141,7 +139,7 @@ jobs: # `isInstalled` short-circuits against the DB the first invocation already # provisioned — so this is a fast no-op check, not a second schema apply. - name: Shared core integration suites (against Supabase Postgres) - run: pnpm --filter @cipherstash/stack run test:integration + run: pnpm exec turbo run test:integration --filter @cipherstash/stack --env-mode=loose env: DATABASE_URL: ${{ steps.db.outputs.database-url }} PGRST_URL: ${{ steps.db.outputs.pgrest-url }} diff --git a/.github/workflows/prisma-next-e2e.yml b/.github/workflows/prisma-next-e2e.yml index 39be3949a..4bccc8dd4 100644 --- a/.github/workflows/prisma-next-e2e.yml +++ b/.github/workflows/prisma-next-e2e.yml @@ -121,7 +121,7 @@ jobs: done - name: Run E2E suite - run: pnpm --filter @cipherstash/prisma-next-example test:e2e + run: pnpm exec turbo run test:e2e --filter @cipherstash/prisma-next-example --env-mode=loose - name: Stop E2E Postgres container if: always() diff --git a/.github/workflows/rebuild-docs.yml b/.github/workflows/rebuild-docs.yml index 80b320950..858732977 100644 --- a/.github/workflows/rebuild-docs.yml +++ b/.github/workflows/rebuild-docs.yml @@ -3,7 +3,7 @@ name: Rebuild Docs on: push: tags: - - '@cipherstash/protect@*' + - '@cipherstash/stack@*' jobs: trigger-docs-rebuild: diff --git a/.github/workflows/tests-bench.yml b/.github/workflows/tests-bench.yml index d36eee339..488b03ba4 100644 --- a/.github/workflows/tests-bench.yml +++ b/.github/workflows/tests-bench.yml @@ -1,20 +1,45 @@ name: "Test Benchmark JS" +# Trigger paths follow the package GRAPH, not the bench directory. bench depends +# on `@cipherstash/stack` and `@cipherstash/stack-drizzle`, and (since the fixture +# schema moved to EQL v3 domains) on the CLI's EQL installer — a break in any of +# them can red this job, so an edit to any of them has to be able to trigger it. +# Scoped the same way the `integration-*` workflows scope theirs rather than +# taking whole packages, to keep the trigger surface honest. on: push: branches: - 'main' paths: - 'packages/bench/**' + - 'packages/stack/src/eql/v3/**' + # The bench drives the typed v3 CLIENT, which lives in `encryption/`, not + # `eql/v3/` (that is the authoring DSL). Without this the job stayed green + # by never running for the changes most likely to break it. + - 'packages/stack/src/encryption/**' + - 'packages/stack-drizzle/**' + - 'packages/test-kit/**' + - 'packages/cli/src/installer/**' - 'local/**' - '.github/workflows/tests-bench.yml' + - '.github/actions/integration-setup/**' pull_request: branches: - "**" + # Repeated verbatim: GitHub Actions does not support YAML anchors/aliases. paths: - 'packages/bench/**' + - 'packages/stack/src/eql/v3/**' + # The bench drives the typed v3 CLIENT, which lives in `encryption/`, not + # `eql/v3/` (that is the authoring DSL). Without this the job stayed green + # by never running for the changes most likely to break it. + - 'packages/stack/src/encryption/**' + - 'packages/stack-drizzle/**' + - 'packages/test-kit/**' + - 'packages/cli/src/installer/**' - 'local/**' - '.github/workflows/tests-bench.yml' + - '.github/actions/integration-setup/**' jobs: tests-bench: @@ -25,25 +50,14 @@ jobs: - name: Checkout Repo uses: actions/checkout@v6 - - uses: pnpm/action-setup@v6.0.9 - name: Install pnpm - with: - run_install: false - - - name: Install Node.js - uses: actions/setup-node@v6.5.0 + # The shared integration preamble, which also builds the `stash` CLI — not + # incidental here: the bench `globalSetup` installs EQL v3 by shelling out + # to the real `stash eql install --eql-version 3`. + - uses: ./.github/actions/integration-setup with: + # This job's existing Node; the composite defaults to 24 for the + # integration suites. node-version: 22 - cache: 'pnpm' - - # node-pty's install hook falls back to `node-gyp rebuild` when no - # linux-x64 prebuild matches. pnpm/action-setup v6 no longer ships - # node-gyp on PATH, so install it explicitly. - - name: Install node-gyp - run: npm install -g node-gyp - - - name: Install dependencies - run: pnpm recursive install --frozen-lockfile # `@cipherstash/stack` ships dist/-based `exports`; bench imports # from `@cipherstash/stack` and `@cipherstash/stack-drizzle` (the Drizzle @@ -54,11 +68,28 @@ jobs: - name: Build stack + adapter packages run: pnpm exec turbo run build --filter @cipherstash/stack --filter @cipherstash/stack-drizzle - # Starts the pinned postgres-eql container (PostgreSQL 17 + EQL - # pre-installed) via local/docker-compose.yml; waits for healthcheck. - - name: Start local Postgres (EQL) + # Deliberately BEFORE Postgres. Separate config, no globalSetup: these + # need neither a database nor credentials, so they must not be reachable + # only after the steps that do — a docker failure below would otherwise + # skip the one check whose whole rationale is that it cannot be skipped. + # The seed keying they check was wrong for the entire v2 -> v3 port + # precisely because every suite that would have caught it needs + # credentials, and `tsc --noEmit` passes on a hand-written row type that + # agrees with itself (#772 review, finding 12). + - name: Run bench unit checks (no database, no credentials) + working-directory: packages/bench + run: pnpm test:unit + + # Service-scoped `postgres`: local/docker-compose.yml also defines a + # PostgREST that bench never talks to. + # + # This container has EQL v2 baked in and NOTHING to do with the v3 domains + # the fixture schema needs — those come from the `globalSetup` install + # below. Do not add an EQL step here; keeping the install inside vitest is + # what makes `pnpm test:local` and CI the same path. + - name: Start local Postgres working-directory: local - run: docker compose up --wait --wait-timeout 60 + run: docker compose up --wait --wait-timeout 60 postgres # `pnpm test:local` resolves to `vitest run`; the trailing `db-only` # is a vitest path filter that narrows to __tests__/db-only.test.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f13e60086..4ece7ea51 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -136,21 +136,97 @@ jobs: - name: Typecheck (prisma-next — enforces v3 operator-capability gating) run: pnpm --filter @cipherstash/prisma-next run typecheck + # `packages/bench` is a live importer of `@cipherstash/stack` and + # `@cipherstash/stack-drizzle`, but it has no `test` script (its suites + # need a database), so `pnpm run test` never reaches it and an adapter + # rename could break it unnoticed. Its `build` is `tsc --noEmit`, and + # turbo's `^build` builds the two adapters first. This also runs in the + # Bun job's full `turbo build`, but that job swallows its own test + # failures — the importer guard should not depend on it. + - name: Typecheck (bench — guards the stack/stack-drizzle importers) + run: pnpm exec turbo run build --filter @cipherstash/bench + + # The wizard is built by tsup, which transpiles without typechecking, so + # nothing here caught the three `auth.AutoStrategy` resolution errors that + # sat in `main` until #771. Gate it so they cannot come back silently. + - name: Typecheck (wizard) + run: pnpm --filter @cipherstash/wizard run typecheck + + # `examples/*` are standalone apps outside the `./packages/*` filter that + # root `build`/`test` use, so nothing in CI compiled them. `examples/basic` + # had been broken since the v2 removal deleted `encryptedType` and the v2 + # `encryptedSupabase` — on a fully green board. Gate it through turbo so + # `^build` builds stack/stack-drizzle/stash first. + - name: Typecheck (examples/basic — guards the v3 stack/stack-drizzle importers) + run: pnpm exec turbo run typecheck --filter @cipherstash/basic-example + + # The rest of the surfaces that were compiling nowhere. Each of these has + # a `tsconfig.json` that covers its `src` and tests, and each was already + # clean — so they are gated now, before they drift. They resolve their + # workspace dependencies through `dist/*.d.ts`, hence the turbo filters + # (`^build` builds the dependencies first). + # + # NOT gated yet, and deliberately: `@cipherstash/stack` (147 errors under + # its own tsconfig), `@cipherstash/stack-drizzle` (63), `stash` (21) and + # `@cipherstash/stack-supabase` (11). Their `test:types` scripts only + # cover `__tests__/**/*.test-d.ts`, so `src`, the runtime test suites and + # `integration/**` compile nowhere. Most of the drizzle and supabase count + # is one root cause — `V3_MATRIX`'s `indexes` union in + # `@cipherstash/test-kit` — see #778. + - name: Typecheck (migrate) + run: pnpm exec turbo run typecheck --filter @cipherstash/migrate + + - name: Typecheck (nextjs) + run: pnpm exec turbo run typecheck --filter @cipherstash/nextjs + + - name: Typecheck (examples/prisma — guards the prisma-next importers) + run: pnpm exec turbo run typecheck --filter @cipherstash/prisma-next-example + + - name: Typecheck (e2e) + run: pnpm exec turbo run typecheck --filter @cipherstash/e2e + + # Everything else typechecks against SOURCE. This one reads the emitted + # `.d.ts`, which is what customers consume — and where typed `encryptQuery` + # sat broken for every column through the whole rc series, because `tsc` + # strips private member types and that was the only place the column's + # domain parameter appeared bare. + - name: Typecheck (stack — emitted declarations, not source) + run: pnpm exec turbo run test:types:dist --filter @cipherstash/stack + + # `stash init` writes a client file into the user's project and tells them + # not to hand-edit it. Nothing compiled that file, so tightening + # `Encryption` to require a non-empty schema set left every `stash init` + # emitting a project that fails its first `tsc` — with CI green (#772 + # review). The fixtures are pinned byte-for-byte to the generator by + # `placeholder-client-fixture.test.ts`. + # Through turbo, not `pnpm run` — the fixtures import `@cipherstash/stack/v3`, + # so this needs that package BUILT. Invoked directly it passed only because + # earlier steps in this job happen to build it via their own `^build`; drop + # or reorder those (they read as guards for other packages, so they look + # independently removable) and this fails `TS2307`, which reads as "the + # scaffold is broken" rather than "you forgot to build" (#787 review). + - name: Typecheck (stash init's scaffolded client) + run: pnpm exec turbo run typecheck:scaffold --filter stash + - name: Lint — no hardcoded package-manager runners run: pnpm run lint:runners + # The gates above only mean something if they compile source. A tsconfig + # with no `include` globs `**/*`, which sweeps the package's own `dist/` + # into the program — and whether `dist/` exists during a gate depends on + # what an earlier step built transitively, so the gate compiles a + # different program in CI than it does locally. + - name: Lint — typecheck gates compile source, not build output + run: pnpm run lint:typecheck-scope + + # Deleting or renaming a package leaves `packages/` references + # dangling in docs and CI config. Nothing else catches it (#760 review). + - name: Lint — no references to deleted package directories + run: pnpm run lint:package-paths + - name: Test — lint script self-tests run: pnpm run test:scripts - - name: Create .env file in ./packages/protect/ - run: | - touch ./packages/protect/.env - echo "CS_WORKSPACE_CRN=${{ vars.CS_WORKSPACE_CRN }}" >> ./packages/protect/.env - echo "CS_CLIENT_ID=${{ vars.CS_CLIENT_ID }}" >> ./packages/protect/.env - echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/protect/.env - echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect/.env - echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/protect/.env - - name: Create .env file in ./packages/stack/ run: | touch ./packages/stack/.env @@ -160,14 +236,6 @@ jobs: echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/stack/.env echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/stack/.env - - name: Create .env file in ./packages/protect-dynamodb/ - run: | - touch ./packages/protect-dynamodb/.env - echo "CS_WORKSPACE_CRN=${{ vars.CS_WORKSPACE_CRN }}" >> ./packages/protect-dynamodb/.env - echo "CS_CLIENT_ID=${{ vars.CS_CLIENT_ID }}" >> ./packages/protect-dynamodb/.env - echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/protect-dynamodb/.env - echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect-dynamodb/.env - # Run TurboRepo tests - name: Run tests run: pnpm run test @@ -353,15 +421,6 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Create .env file in ./packages/protect/ - run: | - touch ./packages/protect/.env - echo "CS_WORKSPACE_CRN=${{ vars.CS_WORKSPACE_CRN }}" >> ./packages/protect/.env - echo "CS_CLIENT_ID=${{ vars.CS_CLIENT_ID }}" >> ./packages/protect/.env - echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/protect/.env - echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect/.env - echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/protect/.env - - name: Create .env file in ./packages/stack/ run: | touch ./packages/stack/.env @@ -371,21 +430,13 @@ jobs: echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/stack/.env echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/stack/.env - - name: Create .env file in ./packages/protect-dynamodb/ - run: | - touch ./packages/protect-dynamodb/.env - echo "CS_WORKSPACE_CRN=${{ vars.CS_WORKSPACE_CRN }}" >> ./packages/protect-dynamodb/.env - echo "CS_CLIENT_ID=${{ vars.CS_CLIENT_ID }}" >> ./packages/protect-dynamodb/.env - echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/protect-dynamodb/.env - echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect-dynamodb/.env - # Build with Node (turbo/tsup need Node), then run tests with Bun - name: Build packages run: pnpm turbo build --filter './packages/*' - name: Run tests with Bun run: | - for dir in packages/schema packages/protect packages/stack packages/protect-dynamodb packages/stack-forge; do + for dir in packages/stack; do if [ -f "$dir/vitest.config.ts" ] || [ -f "$dir/package.json" ]; then echo "--- Testing $dir ---" (cd "$dir" && bunx --bun vitest run) || true diff --git a/AGENTS.md b/AGENTS.md index 8c0566c11..2ce2f337b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,26 +73,19 @@ If these variables are missing, tests that require live encryption will fail or - `packages/stack`: Main package (`@cipherstash/stack`) containing the encryption client and all integrations - Subpath exports: `@cipherstash/stack`, `@cipherstash/stack/client`, `@cipherstash/stack/identity`, `@cipherstash/stack/schema`, `@cipherstash/stack/eql/v3`, `@cipherstash/stack/v3`, `@cipherstash/stack/types`, `@cipherstash/stack/dynamodb`, `@cipherstash/stack/encryption`, `@cipherstash/stack/errors`, `@cipherstash/stack/adapter-kit`, `@cipherstash/stack/wasm-inline` (the Drizzle and Supabase integrations moved to their own packages — see below) -- `packages/protect`: Core encryption library (internal, re-exported via `@cipherstash/stack`) - - `src/index.ts`: Public API (`Encryption`, exports) - - `src/ffi/index.ts`: `EncryptionClient` implementation, bridges to `@cipherstash/protect-ffi` - - `src/ffi/operations/*`: Encrypt/decrypt/model/bulk/query operations (thenable pattern with optional `.withLockContext()`) - - `__tests__/*`: End-to-end and API contract tests (Vitest) - `packages/cli`: The `stash` CLI — auth, init, encryption schema, and database setup (`stash eql install`). Has its own `AGENTS.md`. - `packages/wizard`: AI-powered encryption setup (`@cipherstash/wizard`) - `packages/migrate`: Plaintext-to-encrypted column migration (`@cipherstash/migrate`) — resumable backfill, per-column state - `packages/prisma-next`: Prisma Next integration (`@cipherstash/prisma-next`) — searchable field-level encryption for Postgres. **EQL v3 only**: per-domain constructors (`cipherstash.TextSearch()` / `text()` / `bigIntOrd()` / …) and `cipherstashFromStack` (the `./v3` and `./stack` entries). The EQL v2 surface was removed — the adapter's baseline migration installs the EQL v3 bundle only (works on Supabase as a non-superuser) -- `packages/stack-drizzle`: Drizzle ORM integration (`@cipherstash/stack-drizzle`), depends on `@cipherstash/stack` — EQL v2 (`.`) and EQL v3 (`./v3`). Split out of `@cipherstash/stack`. -- `packages/stack-supabase`: Supabase integration (`@cipherstash/stack-supabase`), depends on `@cipherstash/stack` — `encryptedSupabase` (v2) and `encryptedSupabaseV3` (v3). Split out of `@cipherstash/stack`. -- `packages/schema`: Schema builder utilities and types (`encryptedTable`, `encryptedColumn`, `encryptedField`) +- `packages/stack-drizzle`: Drizzle ORM integration (`@cipherstash/stack-drizzle`), depends on `@cipherstash/stack` — **EQL v3 only**, on the package root (the v2 surface was removed and the old `./v3` subpath collapsed into `.`). Split out of `@cipherstash/stack`. +- `packages/stack-supabase`: Supabase integration (`@cipherstash/stack-supabase`), depends on `@cipherstash/stack` — **EQL v3 only**: `encryptedSupabase` is the v3 factory (`encryptedSupabaseV3` remains as a `@deprecated` alias). Split out of `@cipherstash/stack`. - `packages/nextjs`: Next.js helpers and Clerk integration (`./clerk` export) -- `packages/protect-dynamodb`: DynamoDB helpers (`protectDynamoDB`), built on `@cipherstash/protect`. A fork of the shipping adapter, kept for existing consumers of the standalone package; EQL v2 only. The maintained implementation is `packages/stack/src/dynamodb` (`encryptedDynamoDB`) - `packages/utils`: Shared config (`utils/config`) and logger (`utils/logger`) - `packages/bench`: Performance / index-engagement benchmarks (private, not published) - `e2e/*`: Cross-package end-to-end tests (package managers, supply chain, Prisma example README) - `examples/*`: Working apps (basic, prisma, supabase-worker) - `docs/plans/*`: Internal design plans. User-facing documentation lives at https://cipherstash.com/docs (not in this repo). -- `skills/*`: Agent skills (`stash-cli`, `stash-encryption`, `stash-indexing`, `stash-drizzle`, `stash-dynamodb`, `stash-supabase`, `stash-prisma-next`, `stash-supply-chain-security`) +- `skills/*`: Agent skills (`stash-cli`, `stash-encryption`, `stash-indexing`, `stash-postgres`, `stash-edge`, `stash-drizzle`, `stash-dynamodb`, `stash-supabase`, `stash-prisma-next`, `stash-supply-chain-security`) ## Agent Skills — these ship to customers @@ -120,6 +113,8 @@ nothing type-checks them, and the damage lands in a customer's repo, not ours. | Drizzle / Supabase / Prisma Next / DynamoDB integrations | `skills/stash-drizzle`, `skills/stash-supabase`, `skills/stash-prisma-next`, `skills/stash-dynamodb` | | The rollout/cutover lifecycle (`packages/migrate`, `stash encrypt *`) | `skills/stash-encryption` and `skills/stash-cli` | | The `@cipherstash/eql` pin, `eql install`/`eql migration` behaviour, or index-related SQL guidance | `skills/stash-indexing` | +| The EQL operator/domain surface (`eql_v3.query_*` casts, predicate forms) | `skills/stash-postgres` | +| `packages/stack/src/wasm-inline.ts`, the WASM entry's exports, or `stash env` | `skills/stash-edge` | | pnpm config, CI workflows, dependency policy | `skills/stash-supply-chain-security` | | The durable agent rules themselves | `packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md` | @@ -149,8 +144,8 @@ Three rules to remember when editing CI or pnpm config: ## Key Concepts and APIs -- **Initialization**: `EncryptionV3({ schemas })` returns the typed EQL v3 client. (`Encryption({ schemas })` is the legacy v2 client.) Provide at least one `encryptedTable`. -- **Schema (EQL v3, the documented approach)**: Define tables/columns with `encryptedTable` and the `types.*` concrete-domain factories from `@cipherstash/stack/eql/v3` (`types.TextSearch`, `types.IntegerOrd`, `types.Json`, …) — each domain's query capabilities are fixed by its type; there are no chainable capability tuners. Build the client with `EncryptionV3` from `@cipherstash/stack/v3`. (Legacy EQL v2 — `Encryption` + `encryptedColumn(...).equality().freeTextSearch().orderAndRange().searchableJson()` from `@cipherstash/stack/schema` — remains for existing deployments. DynamoDB accepts both v3 and v2 tables; nested fields work in both — v2 via `encryptedField` groups, v3 via a flat dotted column path (`'profile.ssn': types.TextEq(...)`), since `EncryptedV3TableColumn` admits no nested groups.) +- **Initialization**: `Encryption({ schemas })` is the single client factory. It is overloaded: an array of concrete EQL v3 tables yields the strongly-typed EQL v3 client; a v2/loose schema set yields the nominal client. `EncryptionV3` (from `@cipherstash/stack/v3`) is a deprecated, type-identical alias of `Encryption`. Provide at least one `encryptedTable`. +- **Schema (EQL v3, the documented approach)**: Define tables/columns with `encryptedTable` and the `types.*` concrete-domain factories from `@cipherstash/stack/eql/v3` (`types.TextSearch`, `types.IntegerOrd`, `types.Json`, …) — each domain's query capabilities are fixed by its type; there are no chainable capability tuners. Build the client with `Encryption` (import `encryptedTable`/`types` from `@cipherstash/stack/v3`). The client authors EQL v3 only; the `config.eqlVersion` field and the `@cipherstash/stack/schema` EQL v2 builders (`encryptedColumn(...).equality().freeTextSearch().orderAndRange().searchableJson()`) remain (now `@deprecated`) for reading/migrating legacy v2 data. `decrypt`/`decryptModel` read both v2 and v3 payloads. DynamoDB **writes EQL v3 only; decrypt still reads existing v2 items**; nested fields work in both — v2 via `encryptedField` groups (read only), v3 via a flat dotted column path (`'profile.ssn': types.TextEq(...)`), since `EncryptedV3TableColumn` admits no nested groups. - **Operations** (all return Result-like objects and support chaining `.withLockContext(lockContext)` and `.audit()` when applicable): - `encrypt(plaintext, { table, column })` - `decrypt(encryptedPayload)` @@ -161,8 +156,8 @@ Three rules to remember when editing CI or pnpm config: - `encryptQuery(terms[])` for batch query encryption - **Identity-aware encryption**: Authenticate the client as the end user with `OidcFederationStrategy` (`config.authStrategy`, re-exported from `@cipherstash/stack`), then chain `.withLockContext({ identityClaim })` on operations to bind the data key to a claim. The same claim must be used for encrypt and decrypt. (`LockContext.identify()` from `@cipherstash/stack/identity` is deprecated — the strategy now handles token acquisition; `.withLockContext()` also accepts a `LockContext`.) - **Integrations**: - - **Drizzle ORM**: `encryptedType`, `extractEncryptionSchema`, `createEncryptionOperators` from `@cipherstash/stack-drizzle` (EQL v3 factories from `@cipherstash/stack-drizzle/v3`) - - **Supabase**: `encryptedSupabase` (v2) / `encryptedSupabaseV3` (v3) from `@cipherstash/stack-supabase` + - **Drizzle ORM**: `types.*` column factories, `extractEncryptionSchema`, `createEncryptionOperators` from `@cipherstash/stack-drizzle` + - **Supabase**: `encryptedSupabase` from `@cipherstash/stack-supabase` (EQL v3; `encryptedSupabaseV3` is a `@deprecated` alias) - **DynamoDB**: `encryptedDynamoDB` from `@cipherstash/stack/dynamodb` ## Critical Gotchas (read before coding) @@ -225,11 +220,11 @@ pnpm changeset:publish ## Adding Features Safely (LLM checklist) 1. Identify the target package(s) in `packages/*` and confirm whether changes affect public APIs or payload shapes. -2. If modifying `packages/protect` operations or `EncryptionClient`, ensure: +2. If modifying `packages/stack` encryption operations or `EncryptionClient`, ensure: - The Result contract and error type strings remain stable. - `.withLockContext()` remains available for affected operations. - ESM/CJS exports continue to work (don't break `require`). -3. If changing schema behavior (`packages/schema`), update type definitions and ensure validation still works in `EncryptionClient.init`. +3. If changing schema behavior (`packages/stack` schema builders, `@cipherstash/stack/schema`), update type definitions and ensure validation still works in `EncryptionClient.init`. 4. Add/extend tests in the same package. For features that require live credentials, guard with env checks or provide mock-friendly paths. 5. Run: - `pnpm run code:fix` diff --git a/README.md b/README.md index 0753d24e1..489c7f83a 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,11 @@ **Encryption** ```typescript -import { Encryption, encryptedTable, encryptedColumn } from "@cipherstash/stack"; +import { Encryption, encryptedTable, types } from "@cipherstash/stack/v3"; -// 1. Define your schema +// 1. Define your schema — the column type fixes its query capabilities const users = encryptedTable("users", { - email: encryptedColumn("email").equality().freeTextSearch(), + email: types.TextSearch("email"), // equality + order/range + free-text search }); // 2. Initialize the client @@ -67,7 +67,7 @@ bun add @cipherstash/stack ## Features - **[Searchable encryption](https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption)**: query encrypted data with equality, free text search, range, and [JSONB queries](https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption#jsonb-queries-with-searchablejson). -- **[Type-safe schema](https://cipherstash.com/docs/stack/cipherstash/encryption/schema)**: define encrypted tables and columns with `encryptedTable` / `encryptedColumn` +- **[Type-safe schema](https://cipherstash.com/docs/stack/cipherstash/encryption/schema)**: define encrypted tables and columns with `encryptedTable` and the `types.*` concrete-domain factories - **[Model & bulk operations](https://cipherstash.com/docs/stack/cipherstash/encryption/encrypt-decrypt#model-operations)**: encrypt and decrypt entire objects or batches with `encryptModel` / `bulkEncryptModels`. - **[Identity-aware encryption](https://cipherstash.com/docs/stack/cipherstash/encryption/identity)**: authenticate as the end user with `OidcFederationStrategy` and bind the data key to their identity with `.withLockContext({ identityClaim })` for policy-based access control. diff --git a/SECURITY.md b/SECURITY.md index 85fd4e22b..ba3996d97 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -11,14 +11,11 @@ This repository is the CipherStash Stack monorepo for JavaScript/TypeScript. It | ------- | ----------- | | `@cipherstash/stack` | Main package: encryption client, schema, EQL v3 typed client | | `stash` | CipherStash CLI | -| `@cipherstash/protect` | Core encryption library (re-exported via `@cipherstash/stack`) | -| `@cipherstash/schema` | Schema builder utilities | | `@cipherstash/nextjs` | Next.js helpers | -| `@cipherstash/protect-dynamodb` | DynamoDB helpers | | `@cipherstash/migrate` | Plaintext-to-encrypted column migration tooling | | `@cipherstash/prisma-next` | Prisma Next integration (searchable field-level encryption for Postgres) | -| `@cipherstash/stack-drizzle` | Drizzle ORM integration for `@cipherstash/stack` (EQL v2 + v3) | -| `@cipherstash/stack-supabase` | Supabase integration for `@cipherstash/stack` (EQL v2 + v3) | +| `@cipherstash/stack-drizzle` | Drizzle ORM integration for `@cipherstash/stack` (EQL v3) | +| `@cipherstash/stack-supabase` | Supabase integration for `@cipherstash/stack` (EQL v3) | | `@cipherstash/wizard` | AI-powered encryption setup | **Security fixes are released for the latest release line of each package.** Security reports are welcome for any version, but fixes land in the latest release — if you are running an older major version, plan to upgrade to receive them. diff --git a/docs/query-api-walkthrough.md b/docs/query-api-walkthrough.md index 8559a2ed7..ac44297f9 100644 --- a/docs/query-api-walkthrough.md +++ b/docs/query-api-walkthrough.md @@ -37,7 +37,7 @@ flowchart TD | # | Layer | Entry point | Role | |---|-------|-------------|------| | 1 | Public API | `encryption/index.ts:259/270` `encryptQuery()` | Overloaded: single value → `EncryptQueryOperation`; `ScalarQueryTerm[]` → `BatchEncryptQueryOperation`. | -| 1a | Query builders | `drizzle/operators.ts:976`, `supabase/query-builder.ts:44` | `eq/gt/...` operators & deferred builders that batch-encrypt RHS values, then emit a WHERE clause. | +| 1a | Query builders | `stack-drizzle/src/operators.ts`, `stack-supabase/src/query-builder.ts` | `eq/gt/...` operators & deferred builders that batch-encrypt RHS values, then emit a WHERE clause. | | 2 | Operations | `operations/encrypt-query.ts:41`, `operations/batch-encrypt-query.ts:115` | `execute()`: validate → resolve index → call FFI. `*WithLockContext` resolves `LockContextInput` via `resolveLockContext` before the FFI call. | | 3 | EQL resolution | `helpers/infer-index-type.ts:89`, `types.ts:292` | `resolveIndexType` + `queryTypeToFfi`/`queryTypeToQueryOp` map public `QueryTypeName` → FFI `indexType`/`queryOp`. | | 4 | FFI JS wrapper | `protect-ffi/lib/index.cjs:155` | `encryptQuery`/`encryptQueryBulk` → `wrapAsync(native.*)`. | @@ -73,5 +73,5 @@ flowchart LR - **Client init:** `EncryptionClient.init()` (`encryption/index.ts:81`) calls FFI `newClient()` once; the returned `Client` handle is passed into every `encryptQuery` call. - **`cipherstashclient`** = the CipherStash Client **Rust SDK**, compiled via Neon into the platform `.node` binary inside `@cipherstash/protect-ffi`. It performs the actual crypto and talks to ZeroKMS. - **Result shape:** `EncryptedQueryResult` (`types.ts:175`); shaped by `formatEncryptedResult(..., returnType)` (`eql` vs raw). -- **Version:** `package.json` pins `@cipherstash/protect-ffi@0.24.0` (installed tree observed at `0.23.0` — confirm before relying on it). -- `packages/protect/src/ffi/*` mirrors this flow under the older `protect` package name. +- **Version:** `packages/stack/package.json` pins `@cipherstash/protect-ffi@0.30.0` (`stack-drizzle` and `stack-supabase` pin the same version — they must move in lockstep). +- **Paths:** rows 1–3 and the notes above are relative to `packages/stack/src/`; row 1a is rooted at `packages/`; rows 4–5 are inside the installed `@cipherstash/protect-ffi`. Line numbers drift — search the symbol, don't trust the offset. diff --git a/docs/reference/supabase-sdk.md b/docs/reference/supabase-sdk.md index 6561f255e..220ea7734 100644 --- a/docs/reference/supabase-sdk.md +++ b/docs/reference/supabase-sdk.md @@ -4,14 +4,21 @@ are transparently encrypted on mutations, `::jsonb`-cast on selects, encrypted in filter terms, and decrypted in results. -Two entry points, one query mechanism: +One entry point, EQL v3 only: | Entry point | Schema DSL | Column storage | |---|---|---| -| `encryptedSupabase` | `@cipherstash/stack/schema` (EQL v2) | `eql_v2_encrypted` composite | -| `encryptedSupabaseV3` | `@cipherstash/stack/eql/v3` (EQL v3) | native `public.eql_v3_*` domains | +| `encryptedSupabase` | `@cipherstash/stack/eql/v3` (EQL v3) | native `public.eql_v3_*` domains | -Both filter via **direct EQL operators over PostgREST**: the wrapper encrypts +Rows already written as EQL v2 still decrypt through `@cipherstash/stack`; what +is gone is the ability to author new v2 columns here. + +`encryptedSupabaseV3` remains as a `@deprecated`, type-identical alias. The old +EQL v2 authoring wrapper — `encryptedSupabase({ encryptionClient, +supabaseClient })` — has been removed; the name now binds to the v3 factory +below. + +It filters via **direct EQL operators over PostgREST**: the wrapper encrypts the filter term and emits an ordinary `col term` filter, which resolves to the custom operator defined on the encrypted type (equality by HMAC, range by the ordering term — CLLW-OPE on `_ord` domains, block-ORE on `_ord_ore` — @@ -19,7 +26,7 @@ free-text by bloom-filter containment). ## Quick start (EQL v3) -`encryptedSupabaseV3` is an async factory that **introspects the database at +`encryptedSupabase` is an async factory that **introspects the database at connect time**: it detects EQL v3 columns by their Postgres domain, derives each column's encryption config from the domain, and builds the encryption client internally. Introspection needs a direct Postgres connection @@ -27,14 +34,14 @@ client internally. Introspection needs a direct Postgres connection run in a Worker or the browser. ```typescript -import { encryptedSupabaseV3 } from '@cipherstash/stack-supabase' +import { encryptedSupabase } from '@cipherstash/stack-supabase' // Introspects the database via options.databaseUrl or DATABASE_URL -const es = await encryptedSupabaseV3( +const es = await encryptedSupabase( process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!, ) -// or wrap an existing client: await encryptedSupabaseV3(supabaseClient, options) +// or wrap an existing client: await encryptedSupabase(supabaseClient, options) await es.from('users').insert({ email: 'a@b.com', amount: 30 }) @@ -49,16 +56,14 @@ await es.from('users').select('id, amount').gte('amount', 10).lte('amount', 100) `from(tableName)` takes only the table name — no schema argument; column capabilities come from the introspected domains. -The builder surface is shared across v2 and v3: -`.select/.insert/.update/.upsert/.delete`, +The builder surface is `.select/.insert/.update/.upsert/.delete`, `.eq/.neq/.in/.is/.gt/.gte/.lt/.lte/.match/.or/.not/.filter`, transforms (`.order/.limit/.range/.single/.maybeSingle/.csv/.abortSignal/.throwOnError`), -plus `.withLockContext(lockContext)` and `.audit(config)` — with one fork: -free-text search. v2 exposes `.like/.ilike` (SQL wildcard matching); v3 -exposes `.matches()` (fuzzy bloom token search) on encrypted columns, keeps -`.contains()` for native (exact) containment on plaintext columns, and treats -`like`/`ilike` on an encrypted column as an approximate shim that delegates to -`.matches()` (see "v3 encoding details" below). +plus `.withLockContext(lockContext)` and `.audit(config)`. For free-text +search it exposes `.matches()` (fuzzy bloom token search) on encrypted +columns, keeps `.contains()` for native (exact) containment on plaintext +columns, and treats `like`/`ilike` on an encrypted column as an approximate +shim that delegates to `.matches()` (see "v3 encoding details" below). ### Typing (v3) @@ -68,14 +73,14 @@ tables against the database at construction: ```typescript import { encryptedTable, types } from '@cipherstash/stack/eql/v3' -import { encryptedSupabaseV3 } from '@cipherstash/stack-supabase' +import { encryptedSupabase } from '@cipherstash/stack-supabase' const users = encryptedTable('users', { email: types.TextSearch('email'), // public.eql_v3_text_search amount: types.IntegerOrd('amount'), // public.eql_v3_integer_ord }) -const es = await encryptedSupabaseV3( +const es = await encryptedSupabase( process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!, { schemas: { users } }, @@ -132,11 +137,11 @@ The domains use SQL-standard type names (`integer`, `smallint`, `real`, ### Install EQL ```bash -# v2 (default) +# v3 (the default) stash eql install --supabase -# v3 -stash eql install --eql-version 3 --supabase +# v2 (legacy installs only) +stash eql install --eql-version 2 --supabase ``` For **v2**, `--supabase` selects the opclass-stripped bundle (operator diff --git a/docs/superpowers/specs/2026-07-24-a2-fail-closed-rewrite-design.md b/docs/superpowers/specs/2026-07-24-a2-fail-closed-rewrite-design.md new file mode 100644 index 000000000..f44fa80f4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-a2-fail-closed-rewrite-design.md @@ -0,0 +1,203 @@ +# A-2 — fail-closed inversion for the encrypted ALTER COLUMN rewrite + +**Date:** 2026-07-24 +**Addresses:** A-2 in `.work/2026-07-24-eql-v2-removal-verification.md` +**Base:** `05b7bf07` (A-1/A-3 tokenizer fix) +**Affects:** `packages/cli/src/commands/db/rewrite-migrations.ts`, `packages/wizard/src/lib/rewrite-migrations.ts` (the same file twice) + +## Problem + +`rewriteEncryptedAlterColumns` rewrites an in-place `ALTER COLUMN … SET DATA TYPE +` into an ADD + DROP + RENAME sequence. That sequence is +equivalent to DROP + ADD: it fixes the column's type but destroys its contents. + +The sweep decides whether to rewrite by consulting a corpus-wide index of columns +the migrations give an *encrypted* type (`indexEncryptedColumns`). A column found +there is skipped as `already-encrypted`, because dropping it would destroy +ciphertext with no plaintext left to backfill from. **Everything else is +rewritten** — including a column the corpus never mentions at all. + +That default is fail-open. A column absent from the index is not known to be +plaintext; it is simply unknown. Two reproduced triggers, both from the +verification doc: + +- **(a) Lone ALTER, no source declaration.** The corpus contains the ALTER and + nothing that declares the column. Rewritten today: 1 live `DROP COLUMN`. +- **(b) Cross-directory split.** The declaration lives in `drizzle/` and the + ALTER in `migrations/`. The index is built per directory, so the sweep of + `migrations/` never sees the declaration. Rewritten today: 1 live `DROP + COLUMN` — and the dropped column is already typed `eql_v3_text_eq`, i.e. + ciphertext. + +Trigger (b) is not contrived. `post-agent.ts:163` ships with +`DRIZZLE_OUT_DIRS = ['drizzle', 'migrations', 'src/db/migrations']`, so scanning +multiple directories with a per-directory index *is* the default configuration. + +## Decision + +Invert the default. Rewrite only a column the corpus positively declares and does +not give an encrypted type. Anything unknown is skipped and reported. + +Two alternatives were considered and rejected: + +- **Widen the index across directories** (build one corpus index in + `sweepMigrationDirs` and pass it into each per-directory rewrite). Fixes (b) + with a sharper `already-encrypted` message, but unioning unrelated migration + histories can over-detect *plaintext*, which is itself fail-open. Rejected: + fail-closed already makes (b) safe, and this trades a better message for a new + fail-open surface. +- **Require the declaration in the same file.** A column created in + `0001_init.sql` and altered in `0007_encrypt.sql` is the normal case, so this + would stop rewriting anything. + +## Mechanism + +The encrypted side is already precise: it matches against the known domain list +in `ENCRYPTED_DOMAIN`. So "plaintext" needs no type classification — it is the +residue. A column the corpus **declares** but that is not in the encrypted set is +plaintext. + +That turns "what type is this column?" (a SQL parse) into "does a declaration for +this column exist?" (a name match in a position the code already isolates). No +comma splitting, no paren-depth tracking, no quote state machine. + +`indexEncryptedColumns` becomes `indexColumnDeclarations`, one traversal +returning `{ encrypted, declared }`. `encrypted` is populated exactly as today — +the existing broad scan is deliberately over-detecting and must not regress. +`declared` is populated from two positions: + +1. **Inside a `CREATE TABLE` body**, already captured as group 3 of + `CREATE_TABLE_RE`: scan `"([^"]+)"\s+["a-z]` for declared names. +2. **`ALTER TABLE … ADD COLUMN "col" `**: generalise + `ADD_ENCRYPTED_COLUMN_RE` to accept any type with the same tail. + +`RENAME COLUMN "a" TO "b"` propagates `declared` the way it already propagates +`encrypted`. + +The `\s+["a-z]` tail is what makes the name match a declaration rather than a +mention. A type token always begins with a letter or a double quote, so +`"email" text` and `"email" "public"."eql_v3_text_search"` match, while these do +not — the name is followed by `)`, `,` or an operator: + +```sql +PRIMARY KEY ("id", "name") +FOREIGN KEY ("org_id") REFERENCES "orgs"("id") +CHECK ("age" > 0) +UNIQUE("email") +``` + +**The scan must stay anchored to those two positions.** A whole-file scan would +let `ALTER COLUMN "email" SET DATA TYPE …` match its own `"email" SET` and +declare the very column it is asking about — a self-fulfilling fail-open. + +**Known false positive, accepted.** In +`CONSTRAINT "users_email_unique" UNIQUE("email")` the constraint *name* is +followed by whitespace and a letter, so it registers as a declared column. It is +inert unless a constraint name exactly equals the name of a column that is also +encrypted and undeclared; drizzle's `
__unique` convention makes that +contrived. Documented in the docstring rather than engineered around. + +**Corpus-wide, not migration-ordered.** As with the existing encrypted index, a +declaration in a *later* migration than the ALTER still counts. This is a +pre-existing property, not introduced here. + +### Decision order in the rewrite callback + +Unchanged first step, two steps after it: + +1. `isInsideCommentOrString(original, offset)` → return the match untouched. +2. In `encrypted` → `skip(…, 'already-encrypted')`. +3. Not in `declared` → `skip(…, 'source-unknown')`. +4. Otherwise rewrite. + +Step 2 must precede step 3 so a column that is both declared and encrypted — the +output of a previous sweep, which emits `ADD COLUMN ` plus +`RENAME TO ` — still reports `already-encrypted`, the more specific +and more actionable reason. + +`isInsideCommentOrString` is not touched: it is the subject of A-1/A-3, already +landed in the base commit. + +## Skip reason + +`SkipReason` gains `'source-unknown'`. `describeSkipReason` has no `default` arm, +so the compiler forces the new arm to be written. All three consumers +(`install.ts:660`, `eql/migration.ts:248`, `post-agent.ts:192`) render +`describeSkipReason(reason)` verbatim and need no edit. + +The text names both causes, because the user's remedy differs: + +> the sweep could not find where this column was declared in this migration +> directory, so it cannot tell a plaintext column (safe to rewrite) from one that +> already holds ciphertext (where the rewrite would DROP it). Usually the +> column's `CREATE TABLE` / `ADD COLUMN` lives in a different directory, or the +> migration history was squashed. Check the column's current type in the +> database: if it is plaintext and the table is empty, apply the +> ADD/DROP/RENAME by hand; if it already holds ciphertext, use the staged +> `stash encrypt` lifecycle instead + +## Blast radius + +This is a behaviour change for correct corpora, not only for the bug. Any project +whose swept directory does not contain the column's declaration — squashed +migrations, a baseline pulled with `drizzle-kit pull`, a schema bootstrapped from +a hand-written SQL file — stops being rewritten and starts being flagged for +review. That is the trade being bought, and it is the correct direction: the cost +of a false skip is a statement the user fixes by hand, and the cost of a false +rewrite is irrecoverable data. + +## Testing + +Most existing tests in both files use a bare ALTER with no `CREATE` anywhere and +assert a rewrite; under the inversion they would all become `source-unknown`. +Rather than rewrite each fixture, add a helper that writes a `0000_init.sql` +declaring the plaintext source column. It sorts first and is never rewritten, so +`rewritten`/`skipped` assertions stay as they are and the diff is one line per +test. + +New cases, in **both** test files: + +- Lone ALTER, no declaration anywhere → `skipped` with `source-unknown`, file + unchanged on disk, zero `DROP COLUMN`. +- Declaration in a sibling file in the same directory → rewritten (the + non-regression case). +- Declaration living in the `options.skip` file → still counts as declared. +- `RENAME COLUMN` propagates declared-ness from the original name. +- A column named only inside `PRIMARY KEY (…)` / `REFERENCES "t"("col")` → + **not** declared, so `source-unknown`. +- A column that is both declared and encrypted → still `already-encrypted`, not + `source-unknown` (pins the ordering of steps 2 and 3). + +Wizard file only: + +- The cross-directory split from the verification doc, driven through + `sweepMigrationDirs`: encrypted declaration in `drizzle/`, ALTER in + `migrations/` → `source-unknown`, zero `DROP COLUMN` in the output. + +## Dual-copy sync + +The two files are the same file twice, differing only in the wizard's +`sweepMigrationDirs` / `DirRewriteResult`, its attribution string +(`@cipherstash/wizard` vs `stash`), and a few comment blocks. Apply one fix +twice, then diff the two whitespace-normalised files to confirm nothing new +diverged. + +## Docs and changeset + +- `skills/stash-cli/SKILL.md:393` and `skills/stash-drizzle/SKILL.md:64` both + state that each such statement is rewritten. Both need the qualifier that the + rewrite now requires the column's declaration to be present in the swept + directory, and otherwise flags the statement for review. +- Extend `.changeset/rewriter-never-drops-ciphertext.md` rather than adding a + second changeset: it already ships the sibling `already-encrypted` behaviour + for the same component in the same release, and two changesets would describe + overlapping behaviour. Both `stash` and `@cipherstash/wizard` are already + listed. + +## Out of scope + +- Any change to `isInsideCommentOrString` (A-1/A-3, landed in the base commit). +- Sharing a corpus index across directories in `sweepMigrationDirs` (rejected + above). +- The near-miss scan (`NEAR_MISS_RE`) and its `unrecognised-form` reason, which + are unaffected. diff --git a/docs/superpowers/specs/2026-07-24-encryption-signature-and-typecheck-gates-design.md b/docs/superpowers/specs/2026-07-24-encryption-signature-and-typecheck-gates-design.md new file mode 100644 index 000000000..c8c1cdb93 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-encryption-signature-and-typecheck-gates-design.md @@ -0,0 +1,275 @@ +# `Encryption` signature fixes and typecheck gates + +**Date:** 2026-07-24 +**Branch:** `fix/encryption-signature-and-typecheck-gates` (off `8b3f0b15`) +**Addresses:** A-4, A-6, A-7, C-1 from `.work/2026-07-24-eql-v2-removal-verification.md` +**Issue:** #778 (review remediation for #772) + +## Why these four, and why not more + +A-6 and A-7 exist because `Encryption` is overloaded, and it is overloaded because there +are two kinds of client: the typed EQL v3 one and the nominal one that EQL v2 and loose +introspection-derived schemas need. Two overloads means two discriminators that can +disagree — overload resolution reads the `config` argument, the runtime reads the +`schemas` argument (`encryption/index.ts:947`). Remove EQL v2 and there is one client, +one signature, and both defects delete themselves. + +So this change deliberately does **not** attempt the type-level repair the verification +doc explores (a single generic signature returning `ClientFor`, a discriminated-union +`WireConfig`, `ConfigFor`). That machinery exists only to make two clients coexist +safely, and it would be deleted by the v2 removal that motivates it. It is issue #637 and +it belongs with PRs 9–12. + +What is left is the subset that is either independent of the v2 removal (A-4, C-1) or +cheap and non-throwaway (A-6, A-7). + +## A-4 — non-tuple schema arrays are rejected + +### Problem + +`Encryption({ schemas })` accepts only an array *literal*. Every indirect form fails with +`TS2769`: + +```ts +export const all: AnyV3Table[] = [users, orders] // shared module +await Encryption({ schemas: all }) // TS2769 +``` + +Also broken: `ReadonlyArray` (the type `prisma-next` exposes publicly), +push-built arrays, spreads, and unannotated `const all = [...]`. + +The constraint at `encryption/index.ts:872-877` is a non-empty *tuple*: + +```ts +export function Encryption< + const S extends readonly [AnyV3Table, ...AnyV3Table[]], +>(config: { schemas: S; config?: V3ClientConfig }): Promise> +``` + +It was narrowed to a tuple by `7e0092f3` for one reason: `readonly AnyV3Table[]` admits +`readonly []`, so `Encryption({ schemas: [] })` type-checked and then threw at runtime. + +This is a live break on a released surface. `prisma-next` already had to work around it +with a destructure-and-respread through three coupled edits in `from-stack-v3.ts`; any +customer with schema-building indirection hits the same wall. + +### Design + +Widen the type parameter to the array and move the non-emptiness check to the property, so +`[]` is rejected without the tuple constraining every other form: + +```ts +type NonEmptyV3 = S['length'] extends 0 ? never : S + +export function Encryption(config: { + schemas: NonEmptyV3 + config?: V3ClientConfig +}): Promise> +``` + +`schemas` must resolve through `NonEmptyV3` rather than being wrapped in a conditional +alias applied to the whole config — wrapping defeats `const` inference and degrades the +tuple to an array, losing per-column typing on the literal path. + +The runtime throw at `encryption/index.ts:891` stays as the backstop for JavaScript callers. + +### Acceptance + +A `.test-d.ts` probe pinning both directions: + +- compile: inline literal, shared `AnyV3Table[]`, `ReadonlyArray`, push-built, + spread, unannotated `const`; +- still error: `{ schemas: [] }`, wrong plaintext type for a column's domain, a table that + is not a member of the registered tuple. + +## A-6 — `ReturnType` resolves to the nominal client + +### Problem + +TypeScript's `ReturnType` reads the *last* overload, which is the nominal one. So +`Awaited>` yields `EncryptionClient` even for an all-v3 +schema set, and assigning the real client to it fails: + +```text +Type 'TypedEncryptionClient<…>' is missing the following properties +from type 'EncryptionClient': client, encryptConfig, init +``` + +Overload order cannot fix this — whichever signature is last wins, so one of the two forms +is always mis-resolved. Reordering additionally destroys the typed client, because a v3 +table structurally satisfies `BuildableTable` and so matches the nominal overload. + +This is a regression from the released surface, not a pre-existing wart: all 15 instances +were introduced by `d7ff8471`, which turned a single-signature `EncryptionV3` into an alias +of an overloaded `Encryption`. `packages/bench/src/drizzle/setup.ts:38-45` already carries a +hand-rolled workaround. + +15 sites, none visible to CI: + +| Package | Sites | +|---|---| +| `packages/stack` | `__tests__/dynamodb/encrypted-dynamodb-v3.test.ts` (×3), `__tests__/encrypt-lock-context-guards.test.ts`, `__tests__/encrypt-query-searchable-json.test.ts`, `__tests__/encrypt-query-stevec.test.ts`, `integration/shared/{matrix-crypto,matrix-sql,schema-pg,schema-v3-client}.integration.test.ts` | +| `packages/stack-drizzle` | `integration/{adapter,json-adapter}.ts`, `integration/{lock-context,null-persistence,relational}.integration.test.ts` | + +### Design + +No signature change. `EncryptionClientFor` (`encryption/v3.ts:399-402`) already exists and +is the correct idiom — the prior-art survey in the verification doc found that no library +dispatches a schema-dependent result type through `ReturnType`; they all expose a named +extraction type (`z.infer`, `typeof x.infer`, hono's `Client`). Convert the call sites to +it and document it as *the* way to name the client. + +**`EncryptionClientFor` must be widened in step with A-4.** It carries the same narrow tuple +guard: + +```ts +S extends readonly [AnyV3Table, ...AnyV3Table[]] ? TypedEncryptionClient : EncryptionClient +``` + +Left alone, `EncryptionClientFor` falls through to `EncryptionClient` — +so the type A-6 tells callers to use would silently hand back the nominal client for exactly +the non-tuple schemas A-4 just enabled. It becomes: + +```ts +export type EncryptionClientFor = + S extends readonly AnyV3Table[] + ? S['length'] extends 0 + ? EncryptionClient + : TypedEncryptionClient + : EncryptionClient +``` + +The `readonly []` arm must be checked *inside* the v3 branch and before the tuple is used: +`never extends X` is true, so an empty tuple otherwise satisfies "all elements are v3". + +Erased sites that pass `[schema as never]` (the generic `stack-drizzle` integration adapters) +are declared `EncryptionClientFor`, which resolves to +`TypedEncryptionClient` and accepts `TypedEncryptionClient` +by method bivariance. + +`encryption-overloads.test-d.ts:84-88` currently asserts the defect as expected behaviour and +is green in CI. It is rewritten to assert `EncryptionClientFor` resolves correctly instead. + +## A-7 — the type says nominal, the runtime hands back typed + +### Problem + +Overload resolution matches on the `config` argument; the runtime picks its client by +inspecting the `schemas` (`encryption/index.ts:947`, `isV3Only && eqlVersion === 3`). They +disagree whenever a config is hoisted into a `ClientConfig`-typed variable: + +```ts +const cfg: ClientConfig = {} // not assignable to V3ClientConfig +const c = await Encryption({ schemas: [users], config: cfg }) +// type: EncryptionClient runtime: TypedEncryptionClient +c.init(...) // TypeError: init is not a function +``` + +`ClientConfig.eqlVersion` is `2 | 3`; the v3 overload requires `eqlVersion?: 3`. So the +variable form selects the nominal overload while the runtime still returns the typed client. +This bites whenever the variable's runtime `eqlVersion` is anything but `2` — `{}`, +`{ keyset }`, `{ authStrategy }`: the common case. + +Measured blast radius: `init` is the **only** member on `EncryptionClient.prototype` absent +from the typed client at runtime. The other ten are all present. + +### Design + +Add an `init` passthrough to the object `typedClient()` returns, declared `@internal` on +`TypedEncryptionClient` so `satisfies` still checks the shape: + +```ts +init: (config) => client.init(config), +``` + +This reduces the runtime gap to zero. What remains is a silent capability downgrade — the +type says nominal, so the caller loses the typed surface with no diagnostic. No type-level +design closes that: the runtime inspects values while the type inspects an erasable static +type. It ends when v2 removal collapses the two clients into one. + +### Acceptance + +A unit test reproducing the exact shape (config declared `ClientConfig`, v3 schemas, +`EncryptionClient.prototype.init` stubbed so no credentials are needed) that fails with +`TypeError: client.init is not a function` before the change and passes after. + +## C-1 — typecheck gates + +### Problem + +The root `typecheck` gate for `examples/*` already exists (`.github/workflows/tests.yml:155-161`, +added by `5fab1cf6`) — the verification doc refutes the original framing. The remaining gap is +the surfaces nothing typechecks at all. + +Measured after a full `pnpm run build` (most apparent failures were unbuilt workspace deps +resolving to `dist/*.d.ts`, not real errors): + +| Surface | Errors | Has script | +|---|---|---| +| `packages/bench` | 0 | no | +| `packages/migrate` | 0 | no | +| `packages/prisma-next` | 0 | `typecheck` | +| `packages/test-kit` | 0 | `test:types` | +| `packages/wizard` | 0 | `typecheck` | +| `examples/basic` | 0 | `typecheck` (gated) | +| `examples/prisma` | 0 | `typecheck` (never invoked) | +| `e2e` | 0 | no | +| `packages/nextjs` | 2 | no | +| `packages/stack-supabase` | 11 | `test:types` (narrower config) | +| `packages/cli` | 21 | no | +| `packages/stack-drizzle` | 69 | `test:types` (narrower config) | +| `packages/stack` | 168 | `test:types` (narrower config) | + +The three `test:types` scripts run `vitest --typecheck.only` against a `tsconfig.typecheck.json` +whose `include` is `__tests__/**/*.test-d.ts` — so the package's real `tsconfig.json`, which +covers `src`, `__tests__/**/*.test.ts` and `integration/**`, is never checked. + +### Design + +Gate what is green, and record what is not with its count rather than silently skipping it. + +1. Add `typecheck` scripts (`tsc --noEmit -p tsconfig.json`) to `bench`, `migrate`, `nextjs`, + `e2e`; keep the existing ones on `prisma-next`, `wizard`, `examples/*`. +2. One CI job running the eight green surfaces plus `nextjs`, after `pnpm run build` (they + resolve workspace deps through `dist/*.d.ts`). +3. Fix `packages/nextjs`'s 2 errors: `vi.Mock` is used as a *type* in + `__tests__/nextjs.test.ts:68,81`; it needs `import type { Mock } from 'vitest'`. +4. Add `"outputs": ["dist/**"]` to `turbo.json`'s `build` task. It currently declares none, + so a cached build restores nothing and the `examples/basic` gate — which typechecks + against `packages/stack/dist/*.d.ts` — holds today only because fresh CI runners have no + cache. +5. Record `stack-supabase` (11), `cli` (21), `stack-drizzle` (69), `stack` (168) as + documented follow-ups. + +### Deliberately out of scope + +Making `stack`, `stack-drizzle`, `stack-supabase` and `cli` green. 51 of `stack-drizzle`'s 69 +and all 11 of `stack-supabase`'s are one root cause — `spec.indexes.unique` / `.ore` / `.ope` +against `V3_MATRIX` in `packages/test-kit`'s catalog, where `typedEntries` collapses the spec +to a union whose members do not all carry those keys. A single fix in `test-kit` likely clears +~62 of the ~80 errors across those two packages. That is the highest-leverage next step and it +is its own change. + +## Interactions and ordering + +A-4 and A-6 must land together: widening `Encryption` without widening `EncryptionClientFor` +leaves the documented idiom resolving to the wrong client. + +A-7 is independent but shares the same files, and it removes `init` from A-6's `TS2739` +message (leaving only the private `client` / `encryptConfig` fields), so it lands first to +keep the A-6 diffs legible. + +C-1 is independent of all three. It is sequenced last because the A-6 conversions remove 15 +of the errors in the two packages it reports counts for. + +## Other deliverables + +- Changeset: `@cipherstash/stack` **minor** — A-4 widens a released signature and A-7 adds a + member to `TypedEncryptionClient`. `@cipherstash/stack-drizzle` patch for the integration + adapter conversions. +- Delete the migration paragraph in `.changeset/stack-audit-on-decrypt.md` telling callers to + narrow `AnyV3Table[]` to `readonly [AnyV3Table, ...AnyV3Table[]]` — A-4 makes that advice + obsolete, and it was never correct for `ReadonlyArray` anyway. +- Skills sweep: `skills/stash-encryption/SKILL.md` for any `ReturnType` + guidance and for schema-array examples that the widening now permits. +- `packages/stack/README.md` and `packages/prisma-next` docs for the same. diff --git a/e2e/package.json b/e2e/package.json index 7fd7205eb..dd10ac340 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -5,11 +5,11 @@ "description": "End-to-end tests that exercise built CipherStash binaries and cross-package behaviour.", "type": "module", "scripts": { - "test:e2e": "vitest run" + "test:e2e": "vitest run", + "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { "stash": "workspace:*", - "@cipherstash/protect": "workspace:*", "@cipherstash/stack": "workspace:*", "@cipherstash/wizard": "workspace:*" }, diff --git a/e2e/tests/package-managers.e2e.test.ts b/e2e/tests/package-managers.e2e.test.ts index cf5e0951f..bfe42de02 100644 --- a/e2e/tests/package-managers.e2e.test.ts +++ b/e2e/tests/package-managers.e2e.test.ts @@ -25,10 +25,9 @@ const RUNNER: Record = { const BIN = { cli: resolve(REPO_ROOT, 'packages/cli/dist/bin/stash.js'), wizard: resolve(REPO_ROOT, 'packages/wizard/dist/bin/wizard.js'), - protect: resolve(REPO_ROOT, 'packages/protect/dist/bin/stash.js'), - // The legacy @cipherstash/drizzle `generate-eql-migration` bin is gone with - // the package (protect sunsets at 1.0; @cipherstash/stack-drizzle is the - // successor). + // The legacy @cipherstash/protect `stash` bin and the @cipherstash/drizzle + // `generate-eql-migration` bin are both gone with their packages + // (@cipherstash/stack and @cipherstash/stack-drizzle are the successors). } as const const UA: Record = { diff --git a/examples/basic/encrypt.ts b/examples/basic/encrypt.ts index 140e22605..17f869fd5 100644 --- a/examples/basic/encrypt.ts +++ b/examples/basic/encrypt.ts @@ -1,9 +1,13 @@ import 'dotenv/config' -import { Encryption, encryptedColumn, encryptedTable } from '@cipherstash/stack' +import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3' +// EQL v3: a column's query capabilities are fixed by the domain you pick — +// there are no chainable capability tuners. `types.Text` is storage-only +// (encrypt/decrypt, no queries), which is all this demo needs. Reach for +// `types.TextEq` / `types.TextSearch` when you need to query the column. export const users = encryptedTable('users', { - name: encryptedColumn('name'), + name: types.Text('name'), }) export const client = await Encryption({ diff --git a/examples/basic/index.ts b/examples/basic/index.ts index 63bc282a6..b00ad13fa 100644 --- a/examples/basic/index.ts +++ b/examples/basic/index.ts @@ -1,7 +1,6 @@ import 'dotenv/config' import readline from 'node:readline' import { client, users } from './encrypt' -import { createContact, getAllContacts } from './src/queries/contacts' const rl = readline.createInterface({ input: process.stdin, @@ -69,32 +68,6 @@ async function main() { console.log('Bulk encrypted data:', bulkEncryptResult.data) - // Demonstrate Supabase integration with CipherStash encryption - console.log('\n--- Supabase Integration Demo ---') - - try { - // Example: Create a new contact (would insert into encrypted Supabase table) - console.log('Creating encrypted contact...') - const newContact = { - name: 'John Doe', - email: 'john@example.com', - role: 'Developer', // This field will be encrypted using CipherStash - } - - // Note: This would fail in this basic example since we don't have actual Supabase setup - // but shows the pattern for encrypted Supabase usage - console.log('Contact data to encrypt:', newContact) - - // Example: Fetch contacts (would decrypt results from Supabase) - console.log('Fetching encrypted contacts...') - // const contacts = await getAllContacts() - // console.log('Decrypted contacts:', contacts.data) - } catch (error) { - console.log( - 'Supabase demo skipped (no actual Supabase connection in this basic example)', - ) - } - rl.close() } diff --git a/examples/basic/package.json b/examples/basic/package.json index 1c1de60b3..4a62550ad 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -4,7 +4,8 @@ "version": "1.2.14-rc.4", "type": "module", "scripts": { - "start": "tsx index.ts" + "start": "tsx index.ts", + "typecheck": "tsc --project tsconfig.json --noEmit" }, "keywords": [], "author": "", @@ -13,7 +14,7 @@ "dependencies": { "@cipherstash/stack": "workspace:*", "@cipherstash/stack-drizzle": "workspace:*", - "@cipherstash/stack-supabase": "workspace:*", + "drizzle-orm": "^0.45.2", "dotenv": "^17.4.2", "pg": "8.22.0" }, diff --git a/examples/basic/src/encryption/index.ts b/examples/basic/src/encryption/index.ts index 4f78be08e..fca33a2bf 100644 --- a/examples/basic/src/encryption/index.ts +++ b/examples/basic/src/encryption/index.ts @@ -1,20 +1,15 @@ -import { Encryption } from '@cipherstash/stack' -import { - encryptedType, - extractEncryptionSchema, -} from '@cipherstash/stack-drizzle' +import { Encryption } from '@cipherstash/stack/v3' +import { extractEncryptionSchema, types } from '@cipherstash/stack-drizzle' import { integer, pgTable, timestamp } from 'drizzle-orm/pg-core' +// EQL v3 encrypted columns are concrete Postgres domains built with the +// `types.*` factories. The domain fixes the query capabilities: `TextSearch` +// is equality + order/range + free-text, the v3 equivalent of what the old v2 +// builder spelled `.equality().freeTextSearch()`. export const usersTable = pgTable('users', { id: integer('id').primaryKey().generatedAlwaysAsIdentity(), - email: encryptedType('email', { - equality: true, - freeTextSearch: true, - }), - name: encryptedType('name', { - equality: true, - freeTextSearch: true, - }), + email: types.TextSearch('email'), + name: types.TextSearch('name'), createdAt: timestamp('created_at').defaultNow(), }) diff --git a/examples/basic/src/lib/supabase/encrypted.ts b/examples/basic/src/lib/supabase/encrypted.ts deleted file mode 100644 index 147930987..000000000 --- a/examples/basic/src/lib/supabase/encrypted.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { encryptedSupabase } from '@cipherstash/stack-supabase' -import { contactsTable, encryptionClient } from '../../encryption/index' -import { createServerClient } from './server' - -const supabase = await createServerClient() -export const eSupabase = encryptedSupabase({ - encryptionClient, - supabaseClient: supabase, -}) diff --git a/examples/basic/src/lib/supabase/server.ts b/examples/basic/src/lib/supabase/server.ts deleted file mode 100644 index 967949176..000000000 --- a/examples/basic/src/lib/supabase/server.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createClient } from '@supabase/supabase-js' - -export async function createServerClient() { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL! - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! - - return createClient(supabaseUrl, supabaseKey) -} diff --git a/examples/basic/src/queries/contacts.ts b/examples/basic/src/queries/contacts.ts deleted file mode 100644 index ad8979b39..000000000 --- a/examples/basic/src/queries/contacts.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { contactsTable } from '../encryption/index' -import { eSupabase } from '../lib/supabase/encrypted' - -// Example queries using encrypted Supabase wrapper - -export async function getAllContacts() { - const { data, error } = await eSupabase - .from('contacts', contactsTable) - .select('id, name, email, role') // explicit columns, no * - .order('created_at', { ascending: false }) - - return { data, error } -} - -export async function getContactsByRole(role: string) { - const { data, error } = await eSupabase - .from('contacts', contactsTable) - .select('id, name, email, role') - .eq('role', role) // auto-encrypted - - return { data, error } -} - -export async function searchContactsByName(searchTerm: string) { - const { data, error } = await eSupabase - .from('contacts', contactsTable) - .select('id, name, email, role') - .ilike('name', `%${searchTerm}%`) // auto-encrypted - - return { data, error } -} - -export async function createContact(contact: { - name: string - email: string - role: string -}) { - const { data, error } = await eSupabase - .from('contacts', contactsTable) - .insert(contact) // auto-encrypted - .select('id, name, email, role') - .single() - - return { data, error } -} - -export async function updateContact( - id: string, - updates: Partial<{ name: string; email: string; role: string }>, -) { - const { data, error } = await eSupabase - .from('contacts', contactsTable) - .update(updates) // auto-encrypted - .eq('id', id) - .select('id, name, email, role') - .single() - - return { data, error } -} - -export async function deleteContact(id: string) { - const { error } = await eSupabase - .from('contacts', contactsTable) - .delete() - .eq('id', id) - - return { error } -} diff --git a/examples/basic/tsconfig.json b/examples/basic/tsconfig.json index 238655f2c..28110041f 100644 --- a/examples/basic/tsconfig.json +++ b/examples/basic/tsconfig.json @@ -23,5 +23,10 @@ "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false - } + }, + // The `typecheck` gate must compile source, not build output. TypeScript's + // default `exclude` covers `node_modules` but NOT `dist`, and specifying + // `exclude` replaces that default — hence both. Enforced by + // `pnpm run lint:typecheck-scope`. + "exclude": ["dist", "node_modules"] } diff --git a/package.json b/package.json index 75cbff39a..f25fe0c29 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "license": "MIT", "scripts": { "build": "turbo build --filter './packages/*'", - "build:js": "turbo build --filter './packages/protect' --filter './packages/nextjs'", + "build:js": "turbo build --filter './packages/nextjs'", "changeset": "changeset", "changeset:version": "changeset version", "changeset:publish": "changeset publish", @@ -29,7 +29,9 @@ "clean": "rimraf --glob **/.next **/.turbo **/dist **/node_modules", "code:fix": "biome check --write", "code:check": "biome check", + "lint:package-paths": "node scripts/lint-no-dead-package-paths.mjs", "lint:runners": "node scripts/lint-no-hardcoded-runners.mjs", + "lint:typecheck-scope": "node scripts/lint-typecheck-scope.mjs", "lint:workflow-cache": "node scripts/lint-no-workflow-caching.mjs", "release": "pnpm run build && changeset publish", "test": "turbo test --filter './packages/*'", diff --git a/packages/bench/README.md b/packages/bench/README.md index af5c3e38b..cd5409815 100644 --- a/packages/bench/README.md +++ b/packages/bench/README.md @@ -3,8 +3,9 @@ Performance / index-engagement benchmarks for stack integrations. This package validates that each integration emits SQL that engages the canonical -EQL functional indexes (`eql_v2.hmac_256`, `eql_v2.bloom_filter`, `eql_v2.ste_vec`) -on a Supabase-shaped install (no operator classes). It runs in two layers: +EQL functional indexes (`eql_v3.eq_term`, `eql_v3.match_term`, +`eql_v3.to_ste_vec_query`) on a Supabase-shaped install (no operator classes). +It runs in two layers: 1. **EXPLAIN-shape tests** (`__tests__/`) — vitest tests that assert on `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` output. Pass/fail. Cheap. diff --git a/packages/bench/__benches__/drizzle/operators.bench.ts b/packages/bench/__benches__/drizzle/operators.bench.ts index 4dd7c99af..c7079a5a9 100644 --- a/packages/bench/__benches__/drizzle/operators.bench.ts +++ b/packages/bench/__benches__/drizzle/operators.bench.ts @@ -44,8 +44,15 @@ describe('drizzle', () => { await handle.db.select().from(benchTable).where(where) }) - bench('like (prefix)', async () => { - const where = (await ops.like(benchTable.encText, '%value-00000%')) as SQL + bench('matches (free-text)', async () => { + // A FULL seeded value, not the shared `value` prefix: every row is + // `value-<7 digits>`, so a bare `value` needle matches all 10k rows and the + // bloom index has nothing to narrow — the number would measure a full scan + // rather than the index path this bench exists to measure. + const where = (await ops.matches( + benchTable.encText, + 'value-0000042', + )) as SQL await handle.db.select().from(benchTable).where(where) }) diff --git a/packages/bench/__tests__/drizzle/operators.explain.test.ts b/packages/bench/__tests__/drizzle/operators.explain.test.ts index 9aec76d4f..546f4fb2c 100644 --- a/packages/bench/__tests__/drizzle/operators.explain.test.ts +++ b/packages/bench/__tests__/drizzle/operators.explain.test.ts @@ -104,13 +104,19 @@ async function tryExplainWhere(name: string, where: SQL): Promise { // --- #421: equality + array operators ------------------------------------- // -// `bench_text_hmac_idx` (functional hash on eql_v2.hmac_256) is the expected -// fast path. Pre-fix Drizzle emits bare `=` / `<>` / `IN (...)` which falls -// back to seq scan. Post-fix it emits `eql_v2.hmac_256(col) = -// eql_v2.hmac_256(value)` and the index scan kicks in. +// `bench_text_hmac_idx` (functional hash on eql_v3.eq_term) is the expected +// fast path. The v3 operators do NOT emit that expression directly — they emit +// the wrapper `eql_v3.eq(col, $1::eql_v3.query_text_search)`. It engages the +// index because the wrapper is `LANGUAGE sql IMMUTABLE STRICT` over a single +// SELECT, so the planner inlines it to `eql_v3.eq_term(col) = +// eql_v3.eq_term($1)` — and applies the same inlining to the stored index +// expression, which is how the two meet. Break the inlinability (plpgsql body, +// VOLATILE) and every assertion below silently degrades to a seq scan. +// `scripts/__tests__/bench-index-expressions.test.mjs` pins that contract +// against the shipped bundle without needing a database. // // `eq` and `inArray` are naturally high-selectivity (only a few rows match), -// so the planner should pick the hmac index — assertion enforces it. +// so the planner should pick the eq_term index — assertion enforces it. // // `ne` and `notInArray` are naturally low-selectivity (almost all rows match); // even with the hmac index available the planner correctly chooses a seq @@ -161,14 +167,12 @@ describe('#421: equality and array operators', () => { // We don't yet know which call-shaped forms the planner inlines. Record plan // shape; assertions land in a follow-up once #422 closes. describe('#422: call-shaped operators (recorded, not asserted)', () => { - it('records like / ilike plan shapes', async () => { + it('records matches plan shape', async () => { await tryExplainWhere( - 'like', - (await ops.like(benchTable.encText, '%value-00000%')) as SQL, - ) - await tryExplainWhere( - 'ilike', - (await ops.ilike(benchTable.encText, '%VALUE-00000%')) as SQL, + 'matches', + // A full seeded value — `value` alone is in every row, so the recorded + // plan would say nothing about the bloom index. See operators.bench.ts. + (await ops.matches(benchTable.encText, 'value-0000042')) as SQL, ) }) @@ -190,20 +194,15 @@ describe('#422: call-shaped operators (recorded, not asserted)', () => { ) }) - it('records jsonb operator plan shapes', async () => { - for (const [name, build] of [ - [ - 'jsonbPathQueryFirst', - () => ops.jsonbPathQueryFirst(benchTable.encJsonb, '$.idx'), - ], - ['jsonbGet', () => ops.jsonbGet(benchTable.encJsonb, '$.idx')], - [ - 'jsonbPathExists', - () => ops.jsonbPathExists(benchTable.encJsonb, '$.idx'), - ], - ] as const) { - await tryExplainWhere(name, await build()) - } + it('records encrypted-JSONB plan shapes (contains / selector)', async () => { + await tryExplainWhere( + 'contains', + (await ops.contains(benchTable.encJsonb, { idx: 42 })) as SQL, + ) + await tryExplainWhere( + 'selector.gt', + (await ops.selector(benchTable.encJsonb, '$.idx').gt(5000)) as SQL, + ) }) it('records ORDER BY plan shape (asc / desc)', async () => { diff --git a/packages/bench/__unit__/seed-keys.test.ts b/packages/bench/__unit__/seed-keys.test.ts new file mode 100644 index 000000000..82a356e11 --- /dev/null +++ b/packages/bench/__unit__/seed-keys.test.ts @@ -0,0 +1,50 @@ +/** + * The seed's row keys must be the keys model encryption MATCHES on. + * + * `extractEncryptionSchema` keys the encrypted-table column map by the Drizzle + * table's **JS property** (`encText`), while the column's DB name (`enc_text`) + * is what the builder carries. So `resolveEncryptColumnMap().columnPaths` — the + * list every model operation matches a row's fields against — is the property + * names. A row keyed by DB name matches nothing: `bulkEncryptModels` returns it + * untouched, with no failure, and the insert then puts PLAINTEXT into columns + * typed `eql_v3_*`. + * + * That is exactly what the v2 -> v3 port left behind, and nothing caught it: + * `tsc --noEmit` passes because `BenchPlaintextRow` was hand-written and agreed + * with itself, and CI only runs the `db-only` filter, which never seeds + * (#772 review, finding 12). + * + * Credential-free by construction — this compares two key sets and never + * reaches ZeroKMS. + */ +import { describe, expect, it } from 'vitest' +import { encryptionBenchTable } from '../src/drizzle/setup.js' +import { makePlaintextRow } from '../src/harness/seed.js' + +describe('bench seed rows are keyed for model encryption', () => { + // `resolveEncryptColumnMap` is internal, but it derives `columnPaths` as + // exactly `Object.keys(table.buildColumnKeyMap())` — read the same source. + const columnPaths = Object.keys(encryptionBenchTable.buildColumnKeyMap()) + + it('finds the encrypted columns (guards against an empty comparison)', () => { + expect(columnPaths.length).toBeGreaterThan(0) + }) + + it('emits a field for every encrypted column, under the matched key', () => { + const rowKeys = Object.keys(makePlaintextRow(0)) + + // Every encrypted column must be present in the row, or that column is + // silently never encrypted. + expect(rowKeys).toEqual(expect.arrayContaining([...columnPaths])) + }) + + it('emits no field the matcher would pass through as plaintext', () => { + const rowKeys = Object.keys(makePlaintextRow(0)) + const unmatched = rowKeys.filter((key) => !columnPaths.includes(key)) + + expect( + unmatched, + `these seed fields match no encrypted column, so they would be inserted as plaintext into an eql_v3_* column: ${unmatched.join(', ')}`, + ).toEqual([]) + }) +}) diff --git a/packages/bench/package.json b/packages/bench/package.json index 1aa5b847a..46ef39c61 100644 --- a/packages/bench/package.json +++ b/packages/bench/package.json @@ -5,6 +5,8 @@ "description": "Performance / index-engagement benchmarks for stack integrations (Drizzle, encryptedSupabase, Prisma).", "type": "module", "scripts": { + "build": "tsc --noEmit", + "test:unit": "vitest run --config vitest.unit.config.ts", "db:setup": "tsx src/cli/setup.ts", "db:reset": "tsx src/cli/reset.ts", "test:local": "vitest run", @@ -17,6 +19,7 @@ "pg": "^8.22.0" }, "devDependencies": { + "@cipherstash/test-kit": "workspace:*", "@types/node": "^22.20.1", "@types/pg": "^8.20.0", "tsx": "catalog:repo", diff --git a/packages/bench/sql/schema.sql b/packages/bench/sql/schema.sql index cf861d2be..92e11c4e9 100644 --- a/packages/bench/sql/schema.sql +++ b/packages/bench/sql/schema.sql @@ -1,30 +1,51 @@ -- Bench fixture schema. -- Single bench table covering text / int / jsonb encrypted columns plus the --- three canonical EQL functional indexes: hmac_256 (hash), bloom_filter (GIN), --- ste_vec (GIN). +-- three canonical EQL v3 functional indexes. -- --- We deliberately do NOT create the `eql_v2.encrypted_operator_class` btree --- indexes that ore-benches uses. Encrypted composites for full-feature columns --- (equality + match + ORE) blow past the 2704-byte btree page-size limit, and --- those indexes don't exist on Supabase anyway — the bench's whole job is to --- validate that the functional-index path works. +-- Mirrors `src/drizzle/setup.ts`: the columns are concrete `eql_v3_*` Postgres +-- domains (see `types.TextSearch` / `types.IntegerOrd` / `types.Json`). Install +-- the domains and index-term functions first with `stash eql install --eql-version 3`. +-- +-- Each index expression must be the expression the matching EQL v3 operator +-- INLINES TO, not merely a term extractor for the same column. The adapter +-- emits a function call (`eql_v3.eq(col, term)`, `eql_v3.matches(...)`, +-- `col @> needle`); every one of those is `LANGUAGE sql IMMUTABLE STRICT` +-- with a single-SELECT body, so the planner inlines it, and Postgres applies +-- the same inlining to the stored index expression. The two only meet if the +-- index is built on the inlined form: +-- +-- eql_v3.eq(a, b) -> eql_v3.eq_term(a) = eql_v3.eq_term(b) +-- eql_v3.matches(a, b) -> eql_v3.match_term(a) @> eql_v3.match_term(b) +-- a @> b (json) -> eql_v3.to_ste_vec_query(a)::jsonb +-- @> eql_v3.to_ste_vec_query(b)::jsonb +-- +-- That last one is why the JSON index is NOT on `eql_v3.ste_vec(...)`: that +-- function exists and the index builds happily, but nothing the adapter emits +-- ever mentions it, so the index is dead weight. The bundle says so itself, on +-- `eql_v3."@>"(eql_v3_json_search, eql_v3.query_json)`: "Inlines to native +-- `jsonb @>` over `eql_v3.to_ste_vec_query(a)::jsonb`, so a functional GIN +-- index on the same expression engages." +-- +-- We deliberately do NOT create btree operator-class indexes: encrypted terms for +-- full-feature columns blow past the 2704-byte btree page-size limit, and the +-- bench's whole job is to validate that the functional-index path works. DROP TABLE IF EXISTS bench; CREATE TABLE bench ( id SERIAL PRIMARY KEY, - enc_text eql_v2_encrypted NOT NULL, - enc_int eql_v2_encrypted NOT NULL, - enc_jsonb eql_v2_encrypted NOT NULL + enc_text public.eql_v3_text_search NOT NULL, + enc_int public.eql_v3_integer_ord NOT NULL, + enc_jsonb public.eql_v3_json_search NOT NULL ); CREATE INDEX bench_text_hmac_idx - ON bench USING hash (eql_v2.hmac_256(enc_text)); + ON bench USING hash (eql_v3.eq_term(enc_text)); CREATE INDEX bench_text_bloom_idx - ON bench USING gin (eql_v2.bloom_filter(enc_text)); + ON bench USING gin (eql_v3.match_term(enc_text)); CREATE INDEX bench_jsonb_stevec_idx - ON bench USING gin (eql_v2.ste_vec(enc_jsonb)); + ON bench USING gin ((eql_v3.to_ste_vec_query(enc_jsonb)::jsonb)); ANALYZE bench; diff --git a/packages/bench/src/drizzle/setup.ts b/packages/bench/src/drizzle/setup.ts index 944ece227..9aa08bae5 100644 --- a/packages/bench/src/drizzle/setup.ts +++ b/packages/bench/src/drizzle/setup.ts @@ -1,9 +1,5 @@ -import { Encryption } from '@cipherstash/stack' -import type { EncryptionClient } from '@cipherstash/stack/encryption' -import { - encryptedType, - extractEncryptionSchema, -} from '@cipherstash/stack-drizzle' +import { type EncryptionClientFor, EncryptionV3 } from '@cipherstash/stack/v3' +import { extractEncryptionSchema, types } from '@cipherstash/stack-drizzle' import { drizzle } from 'drizzle-orm/node-postgres' import { pgTable, serial } from 'drizzle-orm/pg-core' import pg from 'pg' @@ -12,48 +8,62 @@ import { getDatabaseUrl } from '../harness/db.js' /** * Drizzle schema for the bench table. Mirrors `sql/schema.sql`. * - * `id` is `serial`; the encrypted columns are `eql_v2_encrypted` composites - * driven by `@cipherstash/stack-drizzle`'s `encryptedType`. + * `id` is `serial`; the encrypted columns are concrete `eql_v3_*` Postgres + * domains emitted by `@cipherstash/stack-drizzle`'s `types.*` factories. * - * Index config flags (`equality`, `freeTextSearch`, `orderAndRange`, - * `searchableJson`) are deliberately all on — the bench needs to exercise - * every query family that lands on the table. + * The domains are chosen to exercise every query family the bench lands on the + * table: `TextSearch` (equality + free-text + order/range), `IntegerOrd` + * (equality + order/range), and `Json` (encrypted-JSONB containment + selector). */ export const benchTable = pgTable('bench', { id: serial('id').primaryKey(), - encText: encryptedType('enc_text', { - equality: true, - freeTextSearch: true, - orderAndRange: true, - }), - encInt: encryptedType('enc_int', { - dataType: 'number', - equality: true, - orderAndRange: true, - }), - encJsonb: encryptedType<{ idx: number; group: number }>('enc_jsonb', { - dataType: 'json', - searchableJson: true, - }), + encText: types.TextSearch('enc_text'), + encInt: types.IntegerOrd('enc_int'), + encJsonb: types.Json('enc_jsonb'), }) /** - * Encryption schema for the stack `Encryption()` client. Derived from the - * Drizzle table above so the two can't drift apart. + * Encryption schema for the `EncryptionV3()` client. Derived from the Drizzle + * table above so the two can't drift apart. */ export const encryptionBenchTable = extractEncryptionSchema(benchTable) +/** + * A seed row, keyed by the Drizzle table's **JS property** names. + * + * That is what model encryption matches on: `extractEncryptionSchema` keys the + * encrypted-table column map by property (`encText`), not by DB column name + * (`enc_text`). A row keyed by DB name matches nothing — `bulkEncryptModels` + * returns it untouched, with no failure, and the plaintext then goes into an + * `eql_v3_*` column (#772 review, finding 12). + * + * HAND-WRITTEN — it cannot be derived from `benchTable` without getting weaker, + * so `__unit__/seed-keys.test.ts` is the real drift guard, not the type. + * Drizzle's `InferInsertModel` describes the ENCRYPTED column (the custom type's + * `data` is the EQL envelope, not the plaintext) and degrades these three to + * optional `any`. Deriving from `encryptionBenchTable` is no better: + * `extractEncryptionSchema` returns the widened `AnyV3Table`, whose column map + * is an index signature — a type that admits `encTxt: 'x'` and `encText: 12345` + * alike, both of which the literal below rejects. + * + * Update it by hand when `benchTable` changes; the unit test will tell you. + */ export type BenchPlaintextRow = { - enc_text: string - enc_int: number - enc_jsonb: { idx: number; group: number } + encText: string + encInt: number + encJsonb: { idx: number; group: number } } +/** The typed EQL v3 client this bench drives. */ +export type BenchEncryptionClient = EncryptionClientFor< + readonly [typeof encryptionBenchTable] +> + export type BenchHandle = { pgClient: pg.Client pool: pg.Pool db: ReturnType - encryptionClient: EncryptionClient + encryptionClient: BenchEncryptionClient } /** @@ -69,7 +79,9 @@ export async function buildBench(): Promise { const db = drizzle(pool) - const encryptionClient = await Encryption({ schemas: [encryptionBenchTable] }) + const encryptionClient = await EncryptionV3({ + schemas: [encryptionBenchTable], + }) return { pgClient, pool, db, encryptionClient } } diff --git a/packages/bench/src/harness/global-setup.ts b/packages/bench/src/harness/global-setup.ts new file mode 100644 index 000000000..ffb318a6e --- /dev/null +++ b/packages/bench/src/harness/global-setup.ts @@ -0,0 +1,31 @@ +// The `/install` subpath, NOT the barrel: the barrel reaches `needle-for.ts`, +// which imports stack source through the `@/` alias, and bench has no +// `stackSourceAlias` in its vitest config (it consumes stack through its +// published dist/ exports). `install.ts` depends on node builtins only. +import { installEqlV3 } from '@cipherstash/test-kit/install' +import { getDatabaseUrl } from './db.js' + +/** + * Install EQL v3 once per run, before any suite applies `sql/schema.sql`. + * + * The fixture schema declares its columns as the concrete `eql_v3_*` domains, so + * the bundle has to be in the database before the first `CREATE TABLE`. Doing it + * here rather than in a CI-only step means the local `pnpm test:local` path and + * the CI path are the same path — the previous arrangement had the workflow rely + * on an EQL-v2-preinstalled image while the schema had already moved to v3, and + * nothing connected the two. + * + * Runs through the real `stash eql install`, matching the integration suites' + * harness (`@cipherstash/test-kit`'s `integration/global-setup.ts`): an installer + * regression fails here instead of hiding behind a test-only SQL apply. + * + * Unlike those suites this needs NO CipherStash credentials — `installEqlV3` + * wants only a database URL — which keeps `db-only.test.ts` the credential-free + * smoke test it is meant to be. + */ +export async function setup(): Promise { + // EXPLICIT, never inferred from the environment. `dbVariant()` would guess + // from `PGRST_URL`, and bench's compose stack happens to define a PostgREST + // it never talks to — a guess here would silently apply the Supabase grants. + await installEqlV3(getDatabaseUrl(), 'postgres') +} diff --git a/packages/bench/src/harness/seed.ts b/packages/bench/src/harness/seed.ts index c25d21c7a..a145c6f6e 100644 --- a/packages/bench/src/harness/seed.ts +++ b/packages/bench/src/harness/seed.ts @@ -24,11 +24,12 @@ export function getTargetRows(): number { return n } -function makePlaintextRow(idx: number): BenchPlaintextRow { +/** Exported so `__unit__/seed-keys.test.ts` can check the row keys. */ +export function makePlaintextRow(idx: number): BenchPlaintextRow { return { - enc_text: `value-${String(idx).padStart(7, '0')}`, - enc_int: idx, - enc_jsonb: { idx, group: idx % 100 }, + encText: `value-${String(idx).padStart(7, '0')}`, + encInt: idx, + encJsonb: { idx, group: idx % 100 }, } } @@ -53,25 +54,22 @@ export async function seed( plaintexts.push(makePlaintextRow(rowsBefore + i)) } - const encResult = - await h.encryptionClient.bulkEncryptModels( - plaintexts, - encryptionBenchTable, - ) + const encResult = await h.encryptionClient.bulkEncryptModels( + plaintexts, + encryptionBenchTable, + ) if (encResult.failure) { throw new Error( `[bench:seed] bulkEncryptModels failed: ${encResult.failure.message}`, ) } - // bulkEncryptModels returns rows keyed by the encryptedTable column names - // (snake_case here). Drizzle's `benchTable` uses camelCase TS field names — - // remap before insert. - const encRows = encResult.data.map((r) => ({ - encText: r.enc_text as unknown as string, - encInt: r.enc_int as unknown as number, - encJsonb: r.enc_jsonb as unknown as { idx: number; group: number }, - })) + // bulkEncryptModels returns rows under the SAME keys it matched on — the + // Drizzle table's JS property names — with EQL v3 envelopes as values. Those + // are the keys `db.insert()` wants, so there is nothing to remap. (The + // remap that used to sit here rewrote enc_text -> encText, which only + // appeared to work: nothing was ever encrypted, so it was moving plaintext.) + const encRows = encResult.data for (let i = 0; i < encRows.length; i += INSERT_BATCH) { const batch = encRows.slice(i, i + INSERT_BATCH) diff --git a/packages/bench/vitest.config.ts b/packages/bench/vitest.config.ts index c0f631c04..ac80b923e 100644 --- a/packages/bench/vitest.config.ts +++ b/packages/bench/vitest.config.ts @@ -3,6 +3,14 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { include: ['__tests__/**/*.test.ts'], + // Installs EQL v3 before any suite applies `sql/schema.sql`, which declares + // its columns as `eql_v3_*` domains. See the file for why this is not a + // CI-only step. + globalSetup: ['./src/harness/global-setup.ts'], + // `@cipherstash/test-kit` is consumed as unbuilt TypeScript source, so it + // must not be externalized — same reason the integration suites carry this + // (`packages/test-kit/src/integration/config.ts`). + server: { deps: { inline: [/packages\/test-kit/] } }, testTimeout: 300_000, hookTimeout: 300_000, pool: 'forks', diff --git a/packages/bench/vitest.unit.config.ts b/packages/bench/vitest.unit.config.ts new file mode 100644 index 000000000..6e8695ad8 --- /dev/null +++ b/packages/bench/vitest.unit.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config' + +/** + * Bench checks that need neither a database nor credentials. + * + * The main config's `globalSetup` installs EQL v3 through the built CLI, so + * every suite under it requires `turbo run build --filter stash` and a live + * Postgres. That is right for the benchmarks, and wrong for a check that only + * compares two key sets — and "it was too expensive to run in CI" is exactly + * how the seed came to insert plaintext into `eql_v3_*` columns unnoticed + * (#772 review, finding 12). + */ +export default defineConfig({ + test: { + include: ['__unit__/**/*.test.ts'], + }, +}) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 73795c3bc..aa65e4d00 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -6,11 +6,24 @@ This package has **two** Vitest configs. Run the right one for the change. | Command | Config | Scope | Needs build? | | --- | --- | --- | --- | -| `pnpm --filter stash test` | `vitest.config.ts` | Unit tests under `src/__tests__/**` and `src/**/__tests__/**` | No | +| `pnpm --filter stash test` | `vitest.config.ts` | Unit tests under `src/__tests__/**` and `src/**/__tests__/**` | **Partly** — needs `@cipherstash/stack` built (see below). Turbo's `^build` supplies it in CI. | | `pnpm --filter stash test:e2e` | `vitest.integration.config.ts` | E2E tests under `tests/e2e/**.e2e.test.ts` driving the built `dist/bin/stash.js` through a real pty (`node-pty`) | **Yes** — run `pnpm --filter stash build` first, or use the turbo `test:e2e` task which depends on `build`. | The unit config explicitly excludes `tests/e2e/**` so the default `pnpm test` -stays fast and self-contained. +stays fast. + +It is **not** fully self-contained, despite running standalone in CI. Some `src` +modules import workspace packages that publish `./dist` only, so an unbuilt +workspace fails at collection with `Failed to resolve entry for package …` +rather than at an assertion. `vitest.config.ts` aliases `@cipherstash/migrate` +to its source to remove one such coupling; `@cipherstash/stack` remains, reached +via `packages/migrate/src/backfill.ts` and a direct import in +`init/lib/__tests__/introspect.test.ts`. Deleting `packages/stack/dist` fails 10 +files. Closing it needs `vitest.shared.ts`'s `stackSourceAlias`, which cannot be +spread into this config: its `'@/'` points at `packages/stack/src` while this +package's points at `packages/cli/src`, and a flat alias map admits only one — +spread it after and stack's entry clobbers the CLI's, spread it before and the +CLI's breaks stack's own source imports (#787 review). ## When to add or update an E2E test diff --git a/packages/cli/__fixtures__/scaffold/drizzle.generated.ts b/packages/cli/__fixtures__/scaffold/drizzle.generated.ts new file mode 100644 index 000000000..755815874 --- /dev/null +++ b/packages/cli/__fixtures__/scaffold/drizzle.generated.ts @@ -0,0 +1,67 @@ +/** + * CipherStash encryption client — placeholder. + * + * `stash init` wrote this file. It is intentionally NOT a real Drizzle + * schema. Your existing schema files (typically under `src/db/`) remain + * authoritative — your agent will edit those directly when you encrypt a + * column, then update the `Encryption({ schemas: [...] })` call below + * to reference the encrypted tables you declared there. + * + * Until that happens, the encryption client is initialised with a single + * placeholder table so that this file compiles, and `stash db push`, + * `stash db validate` and `stash encrypt backfill` refuse to run and point + * back here. (`stash encrypt cutover` / `drop` resolve against the database + * and never read this file.) + * + * This project uses EQL v3. Encrypted columns are concrete Postgres domains + * built with the `types.*` factories from `@cipherstash/stack-drizzle`. + * Each domain's query capabilities are FIXED by the type you pick — there is + * no capability config object. Choose the factory whose capabilities you need: + * types.Text / types.Integer / … storage only (encrypt/decrypt, no queries) + * types.TextEq / types.IntegerEq equality (eq, inArray) + * types.IntegerOrd / types.DateOrd equality + order/range (gt/lt/between/sort) + * types.TextMatch free-text match only + * types.TextSearch equality + order/range + free-text + * types.Json encrypted-JSONB containment + selectors + * + * --- Pattern reference (copy into your real schema, do NOT use as-is) --- + * + * Encrypted twin column for an existing populated column (path 3 — lifecycle): + * + * import { pgTable, integer, text } from 'drizzle-orm/pg-core' + * import { types } from '@cipherstash/stack-drizzle' + * + * export const users = pgTable('users', { + * id: integer('id').primaryKey().generatedAlwaysAsIdentity(), + * email: text('email').notNull(), // existing plaintext, unchanged for now + * email_encrypted: types.TextSearch('email_encrypted'), // encrypted twin, NULLABLE — never .notNull() + * }) + * + * Net-new encrypted column (path 1 — declare encrypted from the start): + * + * export const orders = pgTable('orders', { + * id: integer('id').primaryKey().generatedAlwaysAsIdentity(), + * billing_address: types.TextEq('billing_address'), + * }) + * + * Once you have encrypted tables declared, harvest them and pass to Encryption(): + * + * import { extractEncryptionSchema } from '@cipherstash/stack-drizzle' + * import { Encryption } from '@cipherstash/stack/v3' + * import { users, orders } from './db/schema' + * + * export const encryptionClient = await Encryption({ + * schemas: [extractEncryptionSchema(users), extractEncryptionSchema(orders)], + * }) + */ +import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3' + +// REPLACE THIS. It exists only so this file compiles before you have declared +// any encrypted tables — `Encryption` requires at least one. Swap it for your +// real tables (see the patterns above); `stash db push`, `stash db validate` +// and `stash encrypt backfill` refuse to run while the placeholder is still here. +export const placeholderTable = encryptedTable('__stash_placeholder__', { + replace_me: types.Text('replace_me'), +}) + +export const encryptionClient = await Encryption({ schemas: [placeholderTable] }) diff --git a/packages/cli/__fixtures__/scaffold/generic.generated.ts b/packages/cli/__fixtures__/scaffold/generic.generated.ts new file mode 100644 index 000000000..9f0187adc --- /dev/null +++ b/packages/cli/__fixtures__/scaffold/generic.generated.ts @@ -0,0 +1,62 @@ +/** + * CipherStash encryption client — placeholder. + * + * `stash init` wrote this file. It is intentionally NOT a real schema + * definition. Your existing schema files remain authoritative — your + * agent will declare encrypted columns there and update the + * `Encryption({ schemas: [...] })` call below to reference them. + * + * Until that happens, the encryption client is initialised with a single + * placeholder table so that this file compiles, and `stash db push`, + * `stash db validate` and `stash encrypt backfill` refuse to run and point + * back here. (`stash encrypt cutover` / `drop` resolve against the database + * and never read this file.) + * + * This project uses EQL v3. Encrypted columns are concrete Postgres domains + * built with the `types.*` factories from `@cipherstash/stack/eql/v3` + * (also re-exported from `@cipherstash/stack/v3`). Each domain's query + * capabilities are FIXED by the type you pick — there is no chainable + * capability tuner. Choose the factory whose capabilities you need: + * types.Text / types.Integer / … storage only (encrypt/decrypt, no queries) + * types.TextEq / types.IntegerEq equality + * types.IntegerOrd / types.DateOrd equality + order/range + * types.TextMatch free-text match only + * types.TextSearch equality + order/range + free-text + * types.Json encrypted-JSONB containment + selectors + * + * --- Pattern reference (copy into your real schema, do NOT use as-is) --- + * + * Encrypted twin column for an existing populated column (path 3 — lifecycle): + * + * import { encryptedTable, types } from '@cipherstash/stack/v3' + * + * export const users = encryptedTable('users', { + * email_encrypted: types.TextSearch('email_encrypted'), + * }) + * + * Net-new encrypted column (path 1 — declare encrypted from the start): + * + * export const orders = encryptedTable('orders', { + * billing_address: types.TextEq('billing_address'), + * }) + * + * Once you have encrypted tables declared, pass them to Encryption(): + * + * import { Encryption } from '@cipherstash/stack/v3' + * import { users, orders } from './db/schema' + * + * export const encryptionClient = await Encryption({ + * schemas: [users, orders], + * }) + */ +import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3' + +// REPLACE THIS. It exists only so this file compiles before you have declared +// any encrypted tables — `Encryption` requires at least one. Swap it for your +// real tables (see the patterns above); `stash db push`, `stash db validate` +// and `stash encrypt backfill` refuse to run while the placeholder is still here. +export const placeholderTable = encryptedTable('__stash_placeholder__', { + replace_me: types.Text('replace_me'), +}) + +export const encryptionClient = await Encryption({ schemas: [placeholderTable] }) diff --git a/packages/cli/package.json b/packages/cli/package.json index 8f1c36b90..5cafc5d7f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -41,6 +41,7 @@ "postbuild": "chmod +x ./dist/bin/stash.js", "dev": "tsup --watch", "test": "vitest run", + "typecheck:scaffold": "tsc -p tsconfig.scaffold.json", "test:e2e": "vitest run --config vitest.integration.config.ts", "lint": "biome check ." }, diff --git a/packages/cli/src/__tests__/placeholder-guard-parity.test.ts b/packages/cli/src/__tests__/placeholder-guard-parity.test.ts new file mode 100644 index 000000000..80563e9c2 --- /dev/null +++ b/packages/cli/src/__tests__/placeholder-guard-parity.test.ts @@ -0,0 +1,121 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { PLACEHOLDER_TABLE_NAME } from '@/config/index.js' + +/** + * `stash db push` / `db validate` reach the user's encryption client through + * `loadEncryptConfig`; `stash encrypt backfill` reaches the same file through + * `loadEncryptionContext`. Both must refuse the un-replaced `stash init` + * scaffold, and — because it is one file and one mistake — both must say the + * same thing about it. + * + * The two guards were hand-copied rather than shared, so nothing held them + * together: the message text could drift on one side, and the nullish-config + * case HAD already drifted (#787 review follow-up). This pins the agreement at + * both public seams rather than testing the shared helper directly, which + * would prove only that a function calls itself. + * + * Real jiti, real temp project, real filesystem — the only doubles are + * `process.exit` and `console.error`. + */ +describe('the un-replaced init scaffold, refused identically by both loaders', () => { + let tmpDir: string + let originalCwd: () => string + + const writeProject = (clientBody: string) => { + fs.writeFileSync( + path.join(tmpDir, 'stash.config.ts'), + `export default { + databaseUrl: 'postgresql://u:p@127.0.0.1:5432/db', + client: './client.ts', + }`, + ) + fs.writeFileSync(path.join(tmpDir, 'client.ts'), clientBody) + process.cwd = () => tmpDir + } + + /** The duck-typed table shape `loadEncryptionContext` harvests. */ + const table = (name: string) => + `{ tableName: '${name}', build: () => ({ tableName: '${name}', columns: {} }) }` + + /** Run one loader, capturing whatever it wrote to stderr before exiting. */ + const captureRefusal = async (load: () => Promise) => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const exit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit') + }) + let exited = false + try { + await load() + } catch (err) { + exited = (err as Error).message === 'process.exit' + if (!exited) throw err + } + const message = error.mock.calls.flat().join('\n') + error.mockRestore() + exit.mockRestore() + return { exited, message } + } + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stash-guard-parity-')) + originalCwd = process.cwd + }) + + afterEach(() => { + process.cwd = originalCwd + vi.restoreAllMocks() + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('gives db push and encrypt backfill the same refusal for the same file', async () => { + writeProject( + `export const encryptionClient = { + getEncryptConfig: () => ({ tables: { '${PLACEHOLDER_TABLE_NAME}': {} } }), + } + export const placeholderTable = ${table(PLACEHOLDER_TABLE_NAME)}`, + ) + + const { loadEncryptConfig } = await import('@/config/index.js') + const dbPush = await captureRefusal(() => loadEncryptConfig('./client.ts')) + + const { loadEncryptionContext } = await import( + '../commands/encrypt/context.js' + ) + const backfill = await captureRefusal(() => loadEncryptionContext()) + + expect(dbPush.exited).toBe(true) + expect(backfill.exited).toBe(true) + expect(dbPush.message).toContain('still contains the placeholder table') + expect(backfill.message).toEqual(dbPush.message) + }) + + it('names the cause, not the symptom, when the client has no encrypt config', async () => { + // A client whose `getEncryptConfig()` returns nothing is the same class of + // un-finished setup. `db push` already named it; `encrypt backfill` used to + // fall through to `requireTable`'s `Table "users" was not found … + // Available: (none)` — the symptom-not-cause message this guard exists to + // replace (#787 review follow-up). + writeProject( + `export const encryptionClient = { getEncryptConfig: () => undefined } + export const users = ${table('users')}`, + ) + + const { loadEncryptConfig } = await import('@/config/index.js') + const dbPush = await captureRefusal(() => loadEncryptConfig('./client.ts')) + + const { loadEncryptionContext } = await import( + '../commands/encrypt/context.js' + ) + const backfill = await captureRefusal(() => loadEncryptionContext()) + + expect(dbPush.exited).toBe(true) + expect(backfill.exited).toBe(true) + expect(backfill.message).toContain('no initialized encrypt config') + expect(backfill.message).not.toContain('was not found') + }) +}) diff --git a/packages/cli/src/__tests__/rewrite-migrations.test.ts b/packages/cli/src/__tests__/rewrite-migrations.test.ts index e213cc886..4a0d231bd 100644 --- a/packages/cli/src/__tests__/rewrite-migrations.test.ts +++ b/packages/cli/src/__tests__/rewrite-migrations.test.ts @@ -2,7 +2,10 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { rewriteEncryptedAlterColumns } from '../commands/db/rewrite-migrations.js' +import { + describeSkipReason, + rewriteEncryptedAlterColumns, +} from '../commands/db/rewrite-migrations.js' describe('rewriteEncryptedAlterColumns', () => { let tmpDir: string @@ -15,7 +18,28 @@ describe('rewriteEncryptedAlterColumns', () => { fs.rmSync(tmpDir, { recursive: true, force: true }) }) + /** + * Declare `columns` on `tableRef` as PLAINTEXT, in a migration that sorts + * before every fixture below. + * + * The sweep is fail-closed: it rewrites a column only when the corpus shows + * the column exists and is not already encrypted. A fixture that is just an + * ALTER declares nothing, so it is `source-unknown` by design — a test that + * exercises the REWRITE has to supply the `CREATE TABLE` a real drizzle + * corpus would carry. + * + * `tableRef` is written exactly as it appears in the ALTER, so a pgSchema() + * table passes `'"app"."users"'` and the declaration lands on the same key. + */ + const declarePlaintext = (tableRef: string, ...columns: string[]): void => { + const file = path.join(tmpDir, '0000_declare.sql') + const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '' + const defs = columns.map((column) => `"${column}" text`).join(', ') + fs.writeFileSync(file, `${existing}CREATE TABLE ${tableRef} (${defs});\n`) + } + it('rewrites an in-place ALTER COLUMN with the bare type name', async () => { + declarePlaintext('"transactions"', 'amount') const original = `ALTER TABLE "transactions" ALTER COLUMN "amount" SET DATA TYPE eql_v2_encrypted;\n` const filePath = path.join(tmpDir, '0002_alter.sql') fs.writeFileSync(filePath, original) @@ -37,6 +61,7 @@ describe('rewriteEncryptedAlterColumns', () => { }) it('rewrites the schema-qualified form produced by drizzle-kit', async () => { + declarePlaintext('"users"', 'email') const original = 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE "public"."eql_v2_encrypted";\n' const filePath = path.join(tmpDir, '0003_alter.sql') @@ -54,6 +79,7 @@ describe('rewriteEncryptedAlterColumns', () => { it('rewrites a schema-qualified table produced by pgSchema()', async () => { // drizzle-kit emits `"app"."users"` for a table declared in a pgSchema(); // the old `\s+` between the table and ALTER COLUMN could never cross the `.`. + declarePlaintext('"app"."users"', 'email') const original = 'ALTER TABLE "app"."users" ALTER COLUMN "email" SET DATA TYPE "undefined"."eql_v3_text_search";\n' const filePath = path.join(tmpDir, '0014_qualified.sql') @@ -75,6 +101,7 @@ describe('rewriteEncryptedAlterColumns', () => { }) it('rewrites the "undefined" schema form drizzle-kit emits for bare custom types', async () => { + declarePlaintext('"transactions"', 'amount') const original = 'ALTER TABLE "transactions" ALTER COLUMN "amount" SET DATA TYPE "undefined"."eql_v2_encrypted";\n' const filePath = path.join(tmpDir, '0005_undef.sql') @@ -90,6 +117,7 @@ describe('rewriteEncryptedAlterColumns', () => { }) it('rewrites the double-quoted form produced by stack 0.15.0', async () => { + declarePlaintext('"transactions"', 'description') const original = 'ALTER TABLE "transactions" ALTER COLUMN "description" SET DATA TYPE "undefined".""public"."eql_v2_encrypted"";\n' const filePath = path.join(tmpDir, '0006_double.sql') @@ -117,6 +145,7 @@ describe('rewriteEncryptedAlterColumns', () => { }) it('skips the file passed in options.skip', async () => { + declarePlaintext('"t"', 'c') const install = path.join(tmpDir, '0000_install-eql.sql') const alter = path.join(tmpDir, '0002_alter.sql') fs.writeFileSync(install, 'CREATE SCHEMA eql_v2;\n') @@ -169,6 +198,7 @@ describe('rewriteEncryptedAlterColumns', () => { ] it.each(V3_DOMAINS)('rewrites an ALTER COLUMN to %s', async (domain) => { + declarePlaintext('"users"', 'email') const filePath = path.join(tmpDir, '0007_v3.sql') fs.writeFileSync( filePath, @@ -196,6 +226,7 @@ describe('rewriteEncryptedAlterColumns', () => { ...V3_DOMAINS, 'eql_v2_encrypted', ])('extracts the bare domain %s from a mangled ALTER', async (domain) => { + declarePlaintext('"t"', 'c') const filePath = path.join(tmpDir, '0015_drift.sql') fs.writeFileSync( filePath, @@ -243,6 +274,7 @@ describe('rewriteEncryptedAlterColumns', () => { ] it.each(MANGLED_FORMS)('rewrites the v3 %s form', async (_label, emitted) => { + declarePlaintext('"users"', 'email') const filePath = path.join(tmpDir, '0008_form.sql') fs.writeFileSync( filePath, @@ -265,6 +297,7 @@ describe('rewriteEncryptedAlterColumns', () => { '"undefined"."public.eql_v2_encrypted"', ], ])('rewrites the previously unmatched v2 %s form', async (_label, emitted) => { + declarePlaintext('"users"', 'email') const filePath = path.join(tmpDir, '0009_v2form.sql') fs.writeFileSync( filePath, @@ -281,6 +314,7 @@ describe('rewriteEncryptedAlterColumns', () => { }) it('names the target domain in the guidance comment', async () => { + declarePlaintext('"users"', 'email') const filePath = path.join(tmpDir, '0010_comment.sql') fs.writeFileSync( filePath, @@ -295,6 +329,7 @@ describe('rewriteEncryptedAlterColumns', () => { }) it('notes that constraints/defaults/indexes are not carried over', async () => { + declarePlaintext('"users"', 'email') const filePath = path.join(tmpDir, '0016_constraints.sql') fs.writeFileSync( filePath, @@ -309,6 +344,7 @@ describe('rewriteEncryptedAlterColumns', () => { it('does not terminate the commented UPDATE placeholder with a semicolon', async () => { // A runner that naively splits on `;` must not cut mid-comment. + declarePlaintext('"users"', 'email') const filePath = path.join(tmpDir, '0017_semicolon.sql') fs.writeFileSync( filePath, @@ -328,6 +364,7 @@ describe('rewriteEncryptedAlterColumns', () => { }) it('separates ADD/DROP/RENAME with --> statement-breakpoint, one exec stmt per chunk', async () => { + declarePlaintext('"users"', 'email') const filePath = path.join(tmpDir, '0018_breakpoint.sql') fs.writeFileSync( filePath, @@ -353,6 +390,7 @@ describe('rewriteEncryptedAlterColumns', () => { }) it('rewrites each statement to its own domain when v2 and v3 are mixed', async () => { + declarePlaintext('"a"', 'x', 'y') const filePath = path.join(tmpDir, '0011_mixed.sql') fs.writeFileSync( filePath, @@ -424,6 +462,152 @@ describe('rewriteEncryptedAlterColumns', () => { expect(fs.readFileSync(filePath, 'utf-8')).toBe(original) }) + // A near-miss is quoted back to the user verbatim, so it must read as the + // offending statement alone. NEAR_MISS_RE opens with a lazy `[^;]*?`, which + // can only be bounded by the previous `;` — so without an explicit trim the + // reported "statement" drags in every comment and blank line since then. + it('reports a near-miss without the file-leading comment block', async () => { + const statement = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search USING (email)::eql_v3_text_search;' + const filePath = path.join(tmpDir, '0022_preamble.sql') + fs.writeFileSync( + filePath, + [ + '-- Custom SQL migration file, put your code below! --', + '-- Hand-converts the email column in place.', + '', + statement, + '', + ].join('\n'), + ) + + const { skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(skipped).toHaveLength(1) + expect(skipped[0].statement).toBe(statement) + }) + + // A statement the STRICT matcher already matched but skipped (here: + // source-unknown) is left on disk unchanged, so it still contains + // `SET DATA TYPE` and the broad near-miss scan finds it again. Before the + // preamble regex stripped a leading block comment, that second pass reported + // a DIFFERENT statement string (comment glued to the front) than the strict + // pass's `match.trim()`, so the dedup key never matched and the same + // statement came back twice: once correctly as `source-unknown`, once as + // `unrecognised-form` — contradictory advice (look for a hand-authored + // `USING` clause) for a statement that has none. + // + // The block comment must NOT sit at the very start of the file — that is + // the one shape a previous, narrower version of the preamble regex happened + // to handle. A realistic migration file has a preceding statement, so + // NEAR_MISS_RE's match starts at THAT statement's `;`, dragging the newline + // before the comment in too; a regex that only strips a comment anchored to + // the very start of the match fails here. + it('reports a block-comment-prefixed statement once, not twice', async () => { + const alterSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const filePath = path.join(tmpDir, '0040_block-preamble.sql') + fs.writeFileSync( + filePath, + [ + 'CREATE TABLE "users" ("id" integer PRIMARY KEY);', + '/* note */', + alterSql, + '', + ].join('\n'), + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([ + { file: filePath, statement: alterSql, reason: 'source-unknown' }, + ]) + }) + + // Same bug, but the comment sits on the ALTER's own line rather than its + // own — the preceding statement's `;` still starts the near-miss match + // before the (indented) comment, not at it. + it('reports an indented block-comment-prefixed statement once, not twice', async () => { + const alterSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const filePath = path.join(tmpDir, '0041_block-preamble-indented.sql') + fs.writeFileSync( + filePath, + [ + 'CREATE TABLE "users" ("id" integer PRIMARY KEY);', + ` /* note */ ${alterSql}`, + '', + ].join('\n'), + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([ + { file: filePath, statement: alterSql, reason: 'source-unknown' }, + ]) + }) + + // Same bug again, this time on the OTHER correct reason a near-miss can + // carry: the column is already encrypted, not merely undeclared. + it('reports a block-comment-prefixed statement once, not twice, for an already-encrypted column', async () => { + const alterSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const filePath = path.join(tmpDir, '0042_block-preamble-encrypted.sql') + fs.writeFileSync( + filePath, + [ + 'CREATE TABLE "users" ("id" integer PRIMARY KEY, "email" "public"."eql_v3_text_eq");', + '/* note */', + alterSql, + '', + ].join('\n'), + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([ + { file: filePath, statement: alterSql, reason: 'already-encrypted' }, + ]) + }) + + it('reports a near-miss without a preceding statement-breakpoint marker', async () => { + const statement = + 'ALTER TABLE "users" ALTER COLUMN "meta" SET DATA TYPE eql_v3_json USING (meta)::jsonb;' + const filePath = path.join(tmpDir, '0023_breakpoint-preamble.sql') + fs.writeFileSync( + filePath, + [ + 'CREATE TABLE "users" ("id" integer PRIMARY KEY);', + '--> statement-breakpoint', + statement, + '', + ].join('\n'), + ) + + const { skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(skipped).toHaveLength(1) + expect(skipped[0].statement).toBe(statement) + }) + + it('keeps a multi-line near-miss statement intact after the preamble trim', async () => { + const statement = [ + 'ALTER TABLE "users"', + ' ALTER COLUMN "email"', + ' SET DATA TYPE eql_v3_text_search USING (email)::eql_v3_text_search;', + ].join('\n') + const filePath = path.join(tmpDir, '0024_multiline.sql') + fs.writeFileSync(filePath, `-- leading note\n\n${statement}\n`) + + const { skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(skipped).toHaveLength(1) + expect(skipped[0].statement).toBe(statement) + }) + it('reports no skipped statements for a clean file', async () => { const filePath = path.join(tmpDir, '0020_clean.sql') fs.writeFileSync(filePath, 'CREATE TABLE "t" ("id" integer);\n') @@ -435,6 +619,7 @@ describe('rewriteEncryptedAlterColumns', () => { }) it('reports no skipped statements when the strict rewrite fully handled the file', async () => { + declarePlaintext('"users"', 'email') const filePath = path.join(tmpDir, '0021_handled.sql') fs.writeFileSync( filePath, @@ -447,7 +632,847 @@ describe('rewriteEncryptedAlterColumns', () => { expect(skipped).toEqual([]) }) + // A multi-line replacement inherits the author's `-- ` on line 1 ONLY, so + // rewriting a commented-out ALTER turns lines 2+ — including DROP COLUMN — + // into live SQL. Commented SQL never runs; leave it exactly as written. + describe('commented-out statements', () => { + it.each([ + ['a line comment', '-- '], + ['an indented line comment', ' -- '], + ['a drizzle statement-breakpoint style prefix', '--> '], + ])('leaves an ALTER behind %s untouched', async (_label, prefix) => { + const original = `${prefix}ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n` + const filePath = path.join(tmpDir, '0030_commented.sql') + fs.writeFileSync(filePath, original) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toBe(original) + expect(updated).not.toContain('DROP COLUMN') + }) + + it('leaves an ALTER inside a block comment untouched', async () => { + const original = [ + '/* superseded by 0031', + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + '*/', + '', + ].join('\n') + const filePath = path.join(tmpDir, '0030_block.sql') + fs.writeFileSync(filePath, original) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([]) + expect(fs.readFileSync(filePath, 'utf-8')).toBe(original) + }) + + it('leaves an ALTER inside a NESTED block comment untouched', async () => { + const original = [ + '/* outer /* inner */', + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + '*/', + '', + ].join('\n') + const filePath = path.join(tmpDir, '0030_nested.sql') + fs.writeFileSync(filePath, original) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([]) + expect(fs.readFileSync(filePath, 'utf-8')).toBe(original) + }) + + it('does not report a commented-out near-miss', async () => { + const filePath = path.join(tmpDir, '0030_commented-using.sql') + fs.writeFileSync( + filePath, + '-- ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search USING (email)::eql_v3_text_search;\n', + ) + + const { skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(skipped).toEqual([]) + }) + + // The comment scan must not be fooled by `--` inside a string literal, or + // it would skip a live statement and leave broken SQL to fail at migrate. + it('still rewrites an ALTER that follows a "--" inside a string literal', async () => { + declarePlaintext('"users"', 'email') + const filePath = path.join(tmpDir, '0030_literal.sql') + fs.writeFileSync( + filePath, + [ + `INSERT INTO "notes" ("body") VALUES ('a -- b');`, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\n'), + ) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([filePath]) + expect(fs.readFileSync(filePath, 'utf-8')).toContain( + 'ALTER TABLE "users" DROP COLUMN "email";', + ) + }) + + // An apostrophe inside a DOUBLE-QUOTED identifier is not a string + // delimiter. Reading it as one opens a phantom literal whose "closing" + // quote is the apostrophe in the SAME identifier further down the file — + // PAST the commented-out ALTER — so the scan concludes the ALTER is live + // and rewrites it into a real DROP COLUMN. The CREATE that declared the + // column always sits above the ALTER, so a real corpus produces exactly + // this shape. + it('leaves a commented-out ALTER untouched when an earlier identifier holds an apostrophe', async () => { + const original = [ + 'CREATE TABLE "users" (', + '\t"id" serial PRIMARY KEY NOT NULL,', + '\t"o\'brien_data" text', + ');', + '--> statement-breakpoint', + '-- ALTER TABLE "users" ALTER COLUMN "o\'brien_data" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\n') + const filePath = path.join(tmpDir, '0031_apostrophe.sql') + fs.writeFileSync(filePath, original) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toBe(original) + expect(updated).not.toContain('DROP COLUMN') + }) + + // A statement inside a single-quoted literal is DATA, not SQL. Rewriting it + // splices `--> statement-breakpoint` markers INSIDE the literal, so + // splitting the file the way drizzle's migrator does yields a bare, live + // `ALTER TABLE ... DROP COLUMN ...;` as a chunk of its own. + it('leaves an ALTER inside a string literal untouched', async () => { + const original = [ + `INSERT INTO "audit_log" ("note") VALUES ('the reverted migration read:`, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + `(do not run it again)');`, + '', + ].join('\n') + const filePath = path.join(tmpDir, '0032_string-literal.sql') + fs.writeFileSync(filePath, original) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toBe(original) + expect(updated).not.toContain('DROP COLUMN') + expect(updated).not.toContain('--> statement-breakpoint') + }) + + // An UNTERMINATED quoted identifier must fail the same way an unterminated + // string literal does — by swallowing the rest of the file as inert. If it + // instead runs the scan cursor to the end, the loop exits and every + // commented-out ALTER below it is reported live and rewritten: the same + // destructive outcome as the apostrophe case above, one branch over. + it('leaves a commented-out ALTER untouched after an unterminated quoted identifier', async () => { + const original = [ + 'CREATE TABLE "users" ("id" serial PRIMARY KEY NOT NULL, "email" text);', + '--> statement-breakpoint', + 'SELECT "unclosed FROM users;', + '--> statement-breakpoint', + '-- ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\n') + const filePath = path.join(tmpDir, '0033_unterminated-identifier.sql') + fs.writeFileSync(filePath, original) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toBe(original) + expect(updated).not.toContain('DROP COLUMN') + }) + + // Regression pin, not a bug fix — this already behaves. A commented-out + // ALTER in a CRLF file must come back byte-identical. + it('leaves a commented-out ALTER with CRLF line endings byte-identical', async () => { + const original = [ + 'CREATE TABLE "users" ("id" integer PRIMARY KEY);', + '--> statement-breakpoint', + '-- ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\r\n') + const filePath = path.join(tmpDir, '0033_crlf.sql') + fs.writeFileSync(filePath, original) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([]) + expect(fs.readFileSync(filePath, 'utf-8')).toBe(original) + }) + + // #772 review, finding 1. Quote parity is a whole-file property: anything + // that makes the scanner disagree with Postgres about where a literal ENDS + // shifts every following token, so a commented-out ALTER downstream reads + // as live and is rewritten into a live DROP COLUMN. + // + // The two shapes below both do that. A `$$ … $$` body was documented as + // safe on the grounds that mis-reading one can only make us SKIP — that + // holds for an unterminated literal, but an odd apostrophe count inside the + // body ends a literal EARLY, which is the opposite direction. + it('leaves a commented-out ALTER alone after a dollar-quoted body with an odd apostrophe count', async () => { + declarePlaintext('"users"', 'email') + const original = [ + "CREATE FUNCTION note() RETURNS text AS $$ don't $$ LANGUAGE sql;", + '--> statement-breakpoint', + '-- it\'s ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\n') + const filePath = path.join(tmpDir, '0033_dollar-quoted.sql') + fs.writeFileSync(filePath, original) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toBe(original) + expect(updated).not.toContain('DROP COLUMN') + }) + + it('leaves a commented-out ALTER alone after a tagged dollar-quoted body', async () => { + declarePlaintext('"users"', 'email') + const original = [ + "CREATE FUNCTION note() RETURNS text AS $fn$ don't $fn$ LANGUAGE sql;", + '--> statement-breakpoint', + '-- it\'s ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\n') + const filePath = path.join(tmpDir, '0033_tagged-dollar.sql') + fs.writeFileSync(filePath, original) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([]) + expect(fs.readFileSync(filePath, 'utf-8')).toBe(original) + }) + + // A backslash-escaped quote inside an E'' string does not close it. Reading + // it as a close shifts parity for the rest of the file the same way. + it('leaves a commented-out ALTER alone after an E-string with a backslash-escaped quote', async () => { + declarePlaintext('"users"', 'email') + const original = [ + 'CREATE TABLE "notes" ("body" text);', + '--> statement-breakpoint', + "INSERT INTO \"notes\" VALUES (E'a\\'b');", + '--> statement-breakpoint', + '-- it\'s ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\n') + const filePath = path.join(tmpDir, '0033_estring.sql') + fs.writeFileSync(filePath, original) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toBe(original) + expect(updated).not.toContain('DROP COLUMN') + }) + + // The dollar-quote skip must not swallow live SQL: a `$$` body sitting + // BEFORE a genuine plaintext ALTER still leaves that ALTER rewritable. + it('still rewrites a live ALTER that follows a dollar-quoted body', async () => { + declarePlaintext('"users"', 'email') + const filePath = path.join(tmpDir, '0033_dollar-then-live.sql') + fs.writeFileSync( + filePath, + [ + "CREATE FUNCTION note() RETURNS text AS $$ don't $$ LANGUAGE sql;", + '--> statement-breakpoint', + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\n'), + ) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([filePath]) + expect(fs.readFileSync(filePath, 'utf-8')).toContain('DROP COLUMN') + }) + }) + + // ADD+DROP+RENAME on a column that is ALREADY encrypted drops CIPHERTEXT, and + // unlike the plaintext case there is nothing left anywhere to backfill from. + describe('columns that are already encrypted', () => { + it('refuses to rewrite a domain change on a column created encrypted', async () => { + const create = path.join(tmpDir, '0000_create.sql') + fs.writeFileSync( + create, + [ + 'CREATE TABLE "users" (', + '\t"id" integer PRIMARY KEY,', + '\t"email" "public"."eql_v3_text_eq"', + ');', + '', + ].join('\n'), + ) + const alterSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const alter = path.join(tmpDir, '0001_domain-change.sql') + fs.writeFileSync(alter, `${alterSql}\n`) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(fs.readFileSync(alter, 'utf-8')).toBe(`${alterSql}\n`) + expect(skipped).toEqual([ + { file: alter, statement: alterSql, reason: 'already-encrypted' }, + ]) + }) + + it('refuses to rewrite a domain change on a column ADDed encrypted', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_add.sql'), + 'ALTER TABLE "users" ADD COLUMN "email" eql_v3_text_eq;\n', + ) + const alter = path.join(tmpDir, '0001_domain-change.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toHaveLength(1) + expect(skipped[0].reason).toBe('already-encrypted') + }) + + // An EQL domain installed into a NON-`public` schema is still ciphertext. + // The mangled forms special-case the literal `public`, so without the + // extra alternative this column falls to the plaintext residue and the + // ALTER drops a column full of ciphertext. + it('refuses to rewrite a domain change on a column encrypted in a non-public schema', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + [ + 'CREATE TABLE "users" (', + '\t"id" integer PRIMARY KEY,', + '\t"email" "app"."eql_v3_text_eq"', + ');', + '', + ].join('\n'), + ) + const alter = path.join(tmpDir, '0001_domain-change.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toHaveLength(1) + expect(skipped[0].reason).toBe('already-encrypted') + }) + + // An ARRAY of an EQL domain is still ciphertext. The trailing delimiter + // lookahead must admit `[` or the column falls to the plaintext residue. + it('refuses to rewrite a domain change on a column encrypted as a domain array', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_add.sql'), + 'ALTER TABLE "users" ADD COLUMN "email" public.eql_v3_text_eq[];\n', + ) + const alter = path.join(tmpDir, '0001_domain-change.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toHaveLength(1) + expect(skipped[0].reason).toBe('already-encrypted') + }) + + // A previous sweep of this directory leaves ADD tmp + RENAME behind. The + // column it renamed onto is encrypted, so a later domain change on it is + // just as destructive. + it('follows a RENAME from a previous sweep', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_swept.sql'), + [ + 'ALTER TABLE "users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v3_text_eq";', + '--> statement-breakpoint', + 'ALTER TABLE "users" DROP COLUMN "email";', + '--> statement-breakpoint', + 'ALTER TABLE "users" RENAME COLUMN "email__cipherstash_tmp" TO "email";', + '', + ].join('\n'), + ) + const alter = path.join(tmpDir, '0001_domain-change.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toHaveLength(1) + expect(skipped[0].reason).toBe('already-encrypted') + }) + + it('reports the destructive statement once, not twice', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_add.sql'), + 'ALTER TABLE "users" ADD COLUMN "email" eql_v3_text_eq;\n', + ) + fs.writeFileSync( + path.join(tmpDir, '0001_domain-change.sql'), + [ + '-- Custom SQL migration file, put your code below! --', + '', + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\n'), + ) + + const { skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(skipped).toHaveLength(1) + expect(skipped[0].reason).toBe('already-encrypted') + }) + + // The scoping matters: encrypting `contacts.email` must not be blocked by + // an unrelated `users.email` that happens to share a column name. + it('scopes the check to the table', async () => { + declarePlaintext('"contacts"', 'email') + fs.writeFileSync( + path.join(tmpDir, '0000_add.sql'), + 'ALTER TABLE "users" ADD COLUMN "email" eql_v3_text_eq;\n', + ) + const alter = path.join(tmpDir, '0001_contacts.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "contacts" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([alter]) + expect(skipped).toEqual([]) + }) + + // The ordinary case this rewrite exists for: plaintext today, encrypted + // after the ALTER. Nothing to preserve, so rewrite it. + it('still rewrites a plaintext column created in an earlier migration', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + 'CREATE TABLE "users" (\n\t"id" integer PRIMARY KEY,\n\t"email" text\n);\n', + ) + const alter = path.join(tmpDir, '0001_encrypt.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([alter]) + expect(skipped).toEqual([]) + }) + + // A commented-out ADD never ran, so it says nothing about the live schema. + it('ignores an encrypted ADD COLUMN that is commented out', async () => { + declarePlaintext('"users"', 'email') + fs.writeFileSync( + path.join(tmpDir, '0000_add.sql'), + '-- ALTER TABLE "users" ADD COLUMN "email" eql_v3_text_eq;\n', + ) + const alter = path.join(tmpDir, '0001_encrypt.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([alter]) + }) + + // `options.skip` excludes a file from being EDITED, not from describing the + // schema the other files are altering. + it('honours an encrypted column defined in the skipped file', async () => { + const skipPath = path.join(tmpDir, '0000_install.sql') + fs.writeFileSync( + skipPath, + 'ALTER TABLE "users" ADD COLUMN "email" eql_v3_text_eq;\n', + ) + fs.writeFileSync( + path.join(tmpDir, '0001_domain-change.sql'), + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns( + tmpDir, + { + skip: skipPath, + }, + ) + + expect(rewritten).toEqual([]) + expect(skipped[0]?.reason).toBe('already-encrypted') + }) + + // Ordering pin: this column is BOTH declared plaintext (0000) and made + // encrypted by a previous sweep (0001). `already-encrypted` is the more + // specific reason and must win over `source-unknown`. + it('reports already-encrypted even when the column was also declared plaintext', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + 'CREATE TABLE "users" ("id" integer PRIMARY KEY, "email" text);\n', + ) + fs.writeFileSync( + path.join(tmpDir, '0001_swept.sql'), + [ + 'ALTER TABLE "users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v3_text_eq";', + '--> statement-breakpoint', + 'ALTER TABLE "users" DROP COLUMN "email";', + '--> statement-breakpoint', + 'ALTER TABLE "users" RENAME COLUMN "email__cipherstash_tmp" TO "email";', + '', + ].join('\n'), + ) + const alter = path.join(tmpDir, '0002_domain-change.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toHaveLength(1) + expect(skipped[0].reason).toBe('already-encrypted') + }) + + // A commented-out encrypted column line inside an otherwise LIVE + // CREATE TABLE must still count as encrypted. The comment check applies + // only to the DECLARED scan (over-detecting plaintext costs data); the + // ENCRYPTED scan never re-checks comments inside a live CREATE TABLE, so + // this stays over-detecting — the safe direction — exactly as it was + // before the corpus learned to track declarations at all. + it('still counts a commented-out encrypted column inside a live CREATE TABLE as encrypted', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + [ + 'CREATE TABLE "users" (', + '\t"id" integer,', + '\t-- "email" "public"."eql_v3_text_eq",', + '\t"email" text', + ');', + '', + ].join('\n'), + ) + const alter = path.join(tmpDir, '0001_encrypt.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toHaveLength(1) + expect(skipped[0].reason).toBe('already-encrypted') + }) + + // #772 review, finding 3. The index reads CREATE TABLE, ADD COLUMN and + // RENAME, but never the strict matcher's OWN target. So a corpus where an + // earlier migration already converted the column — realistic wherever a + // previous stack version's ALTER ran before this sweep existed — leaves + // that column looking plaintext, and the SECOND conversion rewrites a + // column that now holds ciphertext. `declared` cannot catch it: the column + // IS declared, as plaintext, by the original CREATE TABLE. + it('rewrites only the first conversion when two ALTERs chain on one column', async () => { + declarePlaintext('"users"', 'email') + const first = path.join(tmpDir, '0002_v2.sql') + fs.writeFileSync( + first, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v2_encrypted;\n', + ) + const secondSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const second = path.join(tmpDir, '0003_v3.sql') + fs.writeFileSync(second, `${secondSql}\n`) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + // The first conversion is the legitimate plaintext -> encrypted one and + // must still happen — flagging it too would be the naive fix. + expect(rewritten).toEqual([first]) + expect(fs.readFileSync(first, 'utf-8')).toContain('DROP COLUMN') + // The second targets ciphertext. Left byte-identical on disk. + expect(fs.readFileSync(second, 'utf-8')).toBe(`${secondSql}\n`) + expect(skipped).toEqual([ + { file: second, statement: secondSql, reason: 'already-encrypted' }, + ]) + }) + + it('flags the second of two chained ALTERs inside a single file', async () => { + declarePlaintext('"users"', 'email') + const secondSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const filePath = path.join(tmpDir, '0002_chain.sql') + fs.writeFileSync( + filePath, + [ + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v2_encrypted;', + secondSql, + '', + ].join('\n'), + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([filePath]) + const updated = fs.readFileSync(filePath, 'utf-8') + // Exactly one conversion was applied, and the v3 statement survives verbatim. + expect(updated.match(/DROP COLUMN/g)?.length).toBe(1) + expect(updated).toContain(secondSql) + expect(skipped).toEqual([ + { file: filePath, statement: secondSql, reason: 'already-encrypted' }, + ]) + }) + + // A chain on DIFFERENT columns is not a chain — each is its own first + // conversion and both must be rewritten. + it('rewrites both when chained ALTERs target different columns', async () => { + declarePlaintext('"users"', 'email', 'name') + const first = path.join(tmpDir, '0002_email.sql') + fs.writeFileSync( + first, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + const second = path.join(tmpDir, '0003_name.sql') + fs.writeFileSync( + second, + 'ALTER TABLE "users" ALTER COLUMN "name" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([first, second]) + expect(skipped).toEqual([]) + }) + + // A commented-out first conversion never ran, so the column is still + // plaintext and the live second conversion is the legitimate one. + it('does not treat a commented-out earlier ALTER as having encrypted the column', async () => { + declarePlaintext('"users"', 'email') + fs.writeFileSync( + path.join(tmpDir, '0002_v2.sql'), + '-- ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v2_encrypted;\n', + ) + const live = path.join(tmpDir, '0003_v3.sql') + fs.writeFileSync( + live, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([live]) + expect(fs.readFileSync(live, 'utf-8')).toContain('DROP COLUMN') + }) + + // #772 review, finding 4. `columnKey` keys on the schema exactly as written, + // but Postgres resolves an unqualified name through `search_path` — so + // `"users"` and `"public"."users"` are the SAME table. drizzle-kit emits + // unqualified; hand-written SQL and this sweep's own output are qualified. + // A corpus mixing the two split the index across two keys. + it('treats "public"."users" and "users" as the same table when checking encryption', async () => { + // 0000 declares the column unqualified — drizzle-kit's default output. + fs.writeFileSync( + path.join(tmpDir, '0000_declare.sql'), + 'CREATE TABLE "users" ("email" text);\n', + ) + // 0001 is a previous sweep's output, schema-qualified. The RENAME leaves + // `email` holding ciphertext, recorded under the QUALIFIED key. + fs.writeFileSync( + path.join(tmpDir, '0001_encrypt.sql'), + [ + 'ALTER TABLE "public"."users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v3_text_eq";', + 'ALTER TABLE "public"."users" DROP COLUMN "email";', + 'ALTER TABLE "public"."users" RENAME COLUMN "email__cipherstash_tmp" TO "email";', + '', + ].join('\n'), + ) + // 0002 changes the domain, written unqualified. + const alterSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const alter = path.join(tmpDir, '0002_domain-change.sql') + fs.writeFileSync(alter, `${alterSql}\n`) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(fs.readFileSync(alter, 'utf-8')).toBe(`${alterSql}\n`) + expect(skipped).toEqual([ + { file: alter, statement: alterSql, reason: 'already-encrypted' }, + ]) + }) + + // The mirror direction: declared/encrypted unqualified, altered qualified. + // Before the fix this reported `source-unknown` — a WRONG reason, telling + // the user the corpus never declares a column it declares two files up. + it('resolves an unqualified declaration against a schema-qualified ALTER', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + 'CREATE TABLE "users" ("email" "public"."eql_v3_text_eq");\n', + ) + const alterSql = + 'ALTER TABLE "public"."users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const alter = path.join(tmpDir, '0001_domain-change.sql') + fs.writeFileSync(alter, `${alterSql}\n`) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([ + { file: alter, statement: alterSql, reason: 'already-encrypted' }, + ]) + }) + + // #772 review, finding 2. CREATE_TABLE_RE's body is lazy up to the first + // `)\s*;`, which can sit inside a `--` comment or a string DEFAULT. The + // body is then truncated and every column declared after that point is + // lost from BOTH indexes — so an encrypted column reads as undeclared. + it('indexes columns declared after a ");" inside a comment in the CREATE TABLE body', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + [ + 'CREATE TABLE "users" (', + '\t"id" text, -- pk (uuid);', + '\t"email" "public"."eql_v3_text_eq"', + ');', + '', + ].join('\n'), + ) + const alterSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const alter = path.join(tmpDir, '0001_domain-change.sql') + fs.writeFileSync(alter, `${alterSql}\n`) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([ + { file: alter, statement: alterSql, reason: 'already-encrypted' }, + ]) + }) + + it('indexes columns declared after a ");" inside a string DEFAULT', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + [ + 'CREATE TABLE "users" (', + '\t"note" text DEFAULT \'see (ticket);\',', + '\t"email" "public"."eql_v3_text_eq"', + ');', + '', + ].join('\n'), + ) + const alterSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const alter = path.join(tmpDir, '0001_domain-change.sql') + fs.writeFileSync(alter, `${alterSql}\n`) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([ + { file: alter, statement: alterSql, reason: 'already-encrypted' }, + ]) + }) + + // The destructive composition: the truncated body loses the encrypted + // column, but a LATER migration re-declares the table so `declared` is + // satisfied from elsewhere — the fail-closed rule passes and the ALTER + // drops a column full of ciphertext. + it('does not drop ciphertext when a truncated CREATE TABLE body hides the encrypted column', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_declare.sql'), + 'CREATE TABLE "users" ("email" text);\n', + ) + fs.writeFileSync( + path.join(tmpDir, '0001_recreate.sql'), + [ + 'DROP TABLE "users";', + '--> statement-breakpoint', + 'CREATE TABLE "users" (', + '\t"id" text, -- pk (uuid);', + '\t"email" "public"."eql_v3_text_eq"', + ');', + '', + ].join('\n'), + ) + const alterSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const alter = path.join(tmpDir, '0002_domain-change.sql') + fs.writeFileSync(alter, `${alterSql}\n`) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(fs.readFileSync(alter, 'utf-8')).toBe(`${alterSql}\n`) + expect(skipped).toEqual([ + { file: alter, statement: alterSql, reason: 'already-encrypted' }, + ]) + }) + + // Only the IMPLICIT schema collapses. A real non-public schema is a + // genuinely different table and must not be conflated with the bare name. + it('does not conflate a non-public schema with the unqualified table', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_app.sql'), + 'CREATE TABLE "app"."users" ("email" "public"."eql_v3_text_eq");\n', + ) + fs.writeFileSync( + path.join(tmpDir, '0001_public.sql'), + 'CREATE TABLE "users" ("email" text);\n', + ) + const alter = path.join(tmpDir, '0002_encrypt.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + // public.users.email is plaintext — app.users.email being encrypted is + // about a different table entirely. + expect(rewritten).toEqual([alter]) + expect(skipped).toEqual([]) + }) + }) + it('handles multiple ALTER statements in one file', async () => { + declarePlaintext('"a"', 'x', 'y') const original = [ 'ALTER TABLE "a" ALTER COLUMN "x" SET DATA TYPE eql_v2_encrypted;', 'ALTER TABLE "a" ALTER COLUMN "y" SET DATA TYPE eql_v2_encrypted;', @@ -464,4 +1489,294 @@ describe('rewriteEncryptedAlterColumns', () => { // Non-matching statement preserved expect(updated).toContain('CREATE INDEX "a_z" ON "a" ("z");') }) + + // Regression pin, not a bug fix — the matchers carry `/gi`, so a + // hand-lowercased migration is rewritten just like drizzle-kit's output. + it('rewrites a lowercase alter table ... set data type', async () => { + declarePlaintext('"users"', 'email') + const filePath = path.join(tmpDir, '0034_lowercase.sql') + fs.writeFileSync( + filePath, + 'alter table "users" alter column "email" set data type eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([filePath]) + expect(skipped).toEqual([]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + 'ALTER TABLE "users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v3_text_search";', + ) + expect(updated).toContain('ALTER TABLE "users" DROP COLUMN "email";') + expect(updated).not.toMatch(/set data type/i) + }) + + // A-2: the sweep is FAIL-CLOSED. A column the corpus never declares is + // UNKNOWN, not plaintext — it may already hold ciphertext, with its + // declaration sitting in a migration directory this sweep never sees. + describe('columns the corpus does not declare', () => { + it('refuses to rewrite a column the corpus never declares', async () => { + const alterSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const alter = path.join(tmpDir, '0001_encrypt.sql') + fs.writeFileSync(alter, `${alterSql}\n`) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + const updated = fs.readFileSync(alter, 'utf-8') + expect(updated).toBe(`${alterSql}\n`) + expect(updated).not.toContain('DROP COLUMN') + expect(skipped).toEqual([ + { file: alter, statement: alterSql, reason: 'source-unknown' }, + ]) + }) + + it('rewrites a column declared plaintext by an ADD COLUMN', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_add.sql'), + 'ALTER TABLE "users" ADD COLUMN "email" text;\n', + ) + const alter = path.join(tmpDir, '0001_encrypt.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([alter]) + expect(skipped).toEqual([]) + }) + + // The skipped file is still part of the corpus: a column's current type + // comes from the migrations that ran before this one, edit-eligible or not. + it('counts a declaration living in the file passed to options.skip', async () => { + const install = path.join(tmpDir, '0000_install-eql.sql') + fs.writeFileSync( + install, + 'CREATE TABLE "users" ("id" integer PRIMARY KEY, "email" text);\n', + ) + const alter = path.join(tmpDir, '0002_encrypt.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns( + tmpDir, + { + skip: install, + }, + ) + + expect(rewritten).toEqual([alter]) + expect(skipped).toEqual([]) + }) + + it('follows a RENAME when deciding a column is declared', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + [ + 'CREATE TABLE "users" ("id" integer PRIMARY KEY, "email_address" text);', + '--> statement-breakpoint', + 'ALTER TABLE "users" RENAME COLUMN "email_address" TO "email";', + '', + ].join('\n'), + ) + const alter = path.join(tmpDir, '0001_encrypt.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([alter]) + expect(skipped).toEqual([]) + }) + + // A name inside a table/key constraint is a MENTION, not a declaration. + // Here every mention is followed by `)` or `,`, which the declaration + // regex's tail alone already rejects. A mention followed by a SQL keyword + // instead (e.g. `CHECK ("email" IS NOT NULL)`) is a separate case, closed + // by the regex's keyword lookahead and pinned by its own tests below. + // Counting either would put the rewrite back on the fail-open path. + it('does not treat a name inside PRIMARY KEY / REFERENCES as a declaration', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + [ + 'CREATE TABLE "sessions" (', + '\t"id" integer,', + '\t"user_id" integer,', + '\tPRIMARY KEY ("id", "email"),', + '\tFOREIGN KEY ("user_id") REFERENCES "users"("email")', + ');', + '', + ].join('\n'), + ) + const alterSql = + 'ALTER TABLE "sessions" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const alter = path.join(tmpDir, '0001_encrypt.sql') + fs.writeFileSync(alter, `${alterSql}\n`) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([ + { file: alter, statement: alterSql, reason: 'source-unknown' }, + ]) + }) + + // A column line commented out INSIDE a live CREATE TABLE never ran, so it + // declares nothing. + it('does not count a column commented out inside a live CREATE TABLE', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + [ + 'CREATE TABLE "users" (', + '\t"id" integer PRIMARY KEY,', + '\t-- "email" text,', + '\t"name" text', + ');', + '', + ].join('\n'), + ) + const alter = path.join(tmpDir, '0001_encrypt.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toHaveLength(1) + expect(skipped[0].reason).toBe('source-unknown') + }) + + // A CHECK predicate mentioning a column has the same `"name" ` + // shape as a declaration — `"email" IS` — but IS is a predicate keyword, + // not a type token. Without the keyword lookahead this would read as a + // declaration and rewrite the column, dropping any ciphertext it already + // holds via a declaration this sweep never sees. + it('does not treat a CHECK predicate mention as a declaration', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + [ + 'CREATE TABLE "users" (', + '\t"id" integer PRIMARY KEY,', + '\tCONSTRAINT "c1" CHECK ("email" IS NOT NULL)', + ');', + '', + ].join('\n'), + ) + const alterSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const alter = path.join(tmpDir, '0001_encrypt.sql') + fs.writeFileSync(alter, `${alterSql}\n`) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + const updated = fs.readFileSync(alter, 'utf-8') + expect(updated).toBe(`${alterSql}\n`) + expect(updated).not.toContain('DROP COLUMN') + expect(skipped).toEqual([ + { file: alter, statement: alterSql, reason: 'source-unknown' }, + ]) + }) + + // A constraint's NAME can coincide with a column's name — drizzle's + // `
__unique` convention makes this contrived, but the keyword + // lookahead closes it regardless: a constraint name is always followed + // immediately by its constraint-type keyword (UNIQUE here), which the + // lookahead excludes. + it('does not let a same-named constraint declare the column', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + [ + 'CREATE TABLE "users" (', + '\t"id" integer PRIMARY KEY,', + '\tCONSTRAINT "email" UNIQUE("id")', + ');', + '', + ].join('\n'), + ) + const alterSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const alter = path.join(tmpDir, '0001_encrypt.sql') + fs.writeFileSync(alter, `${alterSql}\n`) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([ + { file: alter, statement: alterSql, reason: 'source-unknown' }, + ]) + }) + + // The keyword lookahead is pinned to a word boundary specifically so it + // does not eat real type names that merely START WITH a blocked + // keyword's letters: `interval`/`inet` both start "in" (colliding with + // IN) but must still count as genuine declarations. + it('still declares a column whose type name starts with a blocked keyword', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + 'CREATE TABLE "events" ("email" interval, "note" inet);\n', + ) + const alter = path.join(tmpDir, '0001_encrypt.sql') + fs.writeFileSync( + alter, + [ + 'ALTER TABLE "events" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + 'ALTER TABLE "events" ALTER COLUMN "note" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\n'), + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([alter]) + expect(skipped).toEqual([]) + }) + }) +}) + +// The three reasons drive very different user action — re-encrypt through the +// staged lifecycle, fix a hand-authored cast, or go check the database. A +// switch with no `default` arm means a missing case fails the build, but a +// mis-MAPPED case (wiring `source-unknown` to the `already-encrypted` string) +// compiles fine and ships wrong remediation into a data-loss decision. Pin the +// mapping so that swap is caught. +describe('describeSkipReason', () => { + it('describes already-encrypted as a re-encrypt-through-the-lifecycle action', () => { + const text = describeSkipReason('already-encrypted') + expect(text).toContain('ALREADY encrypted') + expect(text).toContain('DROP the ciphertext') + expect(text).toContain('`stash encrypt` lifecycle') + }) + + it('describes unrecognised-form as a hand-authored / unknown cast', () => { + const text = describeSkipReason('unrecognised-form') + expect(text).toContain('SET DATA TYPE ... USING') + expect(text).toContain('fails at migrate time') + }) + + it('describes source-unknown as a go-check-the-database action', () => { + const text = describeSkipReason('source-unknown') + expect(text).toContain('could not find where this column was declared') + expect(text).toContain("Check the column's current type in the database") + }) + + it('gives each reason a distinct description', () => { + const reasons = [ + 'already-encrypted', + 'unrecognised-form', + 'source-unknown', + ] as const + const described = reasons.map(describeSkipReason) + expect(new Set(described).size).toBe(reasons.length) + }) }) diff --git a/packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts b/packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts index 3bfc03804..cf4f085e8 100644 --- a/packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts +++ b/packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts @@ -17,7 +17,15 @@ import { messages } from '../../../messages.js' // through. The spinner instance doubles as the `s` argument. const clack = vi.hoisted(() => ({ spinnerInstance: { start: vi.fn(), stop: vi.fn() }, - log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() }, + // `step` is on the real clack `log`; the sweep's per-statement report calls + // it, so it must be present or the report throws into the catch unseen. + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + step: vi.fn(), + }, intro: vi.fn(), note: vi.fn(), outro: vi.fn(), @@ -165,6 +173,71 @@ describe('generateDrizzleMigration', () => { expect(written).toContain('cs_migrations') }) + // Step 5 of the generator sweeps sibling migrations. This block had no test at + // all: every other test's out dir contains only the file the generator wrote + // (which is `skip`ped), so the sweep never had anything to act on. A sibling + // ALTER on a plaintext column the corpus declares must be rewritten. + it('rewrites a sibling ALTER on a declared plaintext column', async () => { + const out = join(tmp, 'drizzle') + mkdirSync(out, { recursive: true }) + writeFileSync( + join(out, '0000_declare.sql'), + 'CREATE TABLE "users" ("email" text);\n', + ) + const sibling = join(out, '0001_encrypt-email.sql') + writeFileSync( + sibling, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE "public"."eql_v3_text_search";\n', + ) + spawnMock.mockImplementation(() => { + writeFileSync(join(out, '0002_install-eql.sql'), '') + return { status: 0, stdout: '', stderr: '' } + }) + + await generateDrizzleMigration(spinner, { out }) + + const rewritten = readFileSync(sibling, 'utf-8') + expect(rewritten).toContain( + 'ALTER TABLE "users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v3_text_search";', + ) + expect(rewritten).not.toContain('SET DATA TYPE') + const info = clack.log.info.mock.calls.map((c) => String(c[0])) + expect(info.some((msg) => msg.includes('Rewrote 1 migration file'))).toBe( + true, + ) + }) + + // A sibling ALTER on a column the corpus never declares is fail-closed to + // `source-unknown`: left on disk and reported with its remediation. Exercises + // the skip branch of the step-5 report, which no db-install test reached. + it('reports source-unknown for a sibling ALTER on an undeclared column', async () => { + const out = join(tmp, 'drizzle') + mkdirSync(out, { recursive: true }) + const sibling = join(out, '0001_encrypt-email.sql') + const unsafeAlter = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE "public"."eql_v3_text_search";\n' + writeFileSync(sibling, unsafeAlter) + spawnMock.mockImplementation(() => { + writeFileSync(join(out, '0002_install-eql.sql'), '') + return { status: 0, stdout: '', stderr: '' } + }) + + await generateDrizzleMigration(spinner, { out }) + + // Never rewritten — its current type is unknown and could be ciphertext. + expect(readFileSync(sibling, 'utf-8')).toBe(unsafeAlter) + const stepped = clack.log.step.mock.calls.map((c) => String(c[0])) + expect( + stepped.some((msg) => + msg.includes("Check the column's current type in the database"), + ), + ).toBe(true) + const warned = clack.log.warn.mock.calls.map((c) => String(c[0])) + expect(warned.some((msg) => msg.includes('did not fully complete'))).toBe( + true, + ) + }) + it('includes --out in the dry-run preview', async () => { const out = join(tmp, 'custom-out') await generateDrizzleMigration(spinner, { dryRun: true, out }) diff --git a/packages/cli/src/commands/db/install.ts b/packages/cli/src/commands/db/install.ts index 8b230552f..60cfb0aae 100644 --- a/packages/cli/src/commands/db/install.ts +++ b/packages/cli/src/commands/db/install.ts @@ -34,7 +34,10 @@ import { detectSupabaseProject, type SupabaseProjectInfo, } from './detect.js' -import { rewriteEncryptedAlterColumns } from './rewrite-migrations.js' +import { + describeSkipReason, + rewriteEncryptedAlterColumns, +} from './rewrite-migrations.js' import { SUPABASE_EQL_MIGRATION_FILENAME, writeSupabaseEqlMigration, @@ -657,10 +660,11 @@ export async function generateDrizzleMigration( if (skipped.length > 0) { sweepIncomplete = true p.log.warn( - `Found ${skipped.length} ALTER-to-encrypted statement(s) the sweep could not rewrite automatically. Review and fix them before running your migrations:`, + `Found ${skipped.length} ALTER-to-encrypted statement(s) the sweep left alone. Review and fix them before running your migrations:`, ) - for (const { file, statement } of skipped) { + for (const { file, statement, reason } of skipped) { p.log.step(` - ${file}: ${statement}`) + p.log.step(` ${describeSkipReason(reason)}`) } } } catch (error) { diff --git a/packages/cli/src/commands/db/rewrite-migrations.ts b/packages/cli/src/commands/db/rewrite-migrations.ts index a96dc5229..3852853f7 100644 --- a/packages/cli/src/commands/db/rewrite-migrations.ts +++ b/packages/cli/src/commands/db/rewrite-migrations.ts @@ -95,12 +95,559 @@ const ALTER_COLUMN_TO_ENCRYPTED_RE = new RegExp( const NEAR_MISS_RE = /[^;]*?\bSET\s+DATA\s+TYPE\b[^;]*?\beql_v[23][a-z0-9_]*[^;]*?;/gi +/** + * Strips any run of blank lines, `--` line comments (including drizzle-kit's + * `--> statement-breakpoint`), and `/* … *\/` block comments — in any order, + * repeated as many times as they occur — from the head of a + * {@link NEAR_MISS_RE} match. + * + * That regex opens with a lazy `[^;]*?`, whose only left boundary is the + * previous `;` — or the start of the file when there is no preceding statement. + * So the raw match drags in every comment and blank line since then, and a + * near-miss preceded by a comment block gets reported to the user with that + * whole block glued to its front. Strip the preamble so the statement we quote + * back reads as the offending statement alone. + * + * The block comment is matched as its OWN loop alternative rather than a + * single group anchored ahead of the line-comment loop, because the latter + * only works when the block comment sits at the very start of the file: in + * the far more common case — a preceding statement earlier in the same file — + * the match starts at THAT statement's `;`, so it opens with the newline + * after it, not with the comment. An anchored `^(?:/\*…\*\/\s*)?` can't match + * past that newline to reach the comment, so it silently matches nothing and + * leaves the comment attached to the reported statement. Folding the block + * comment into the repeating loop lets it match after any number of leading + * newlines/line-comments, in any interleaving. + * + * The block comment strip is NOT cosmetic: a statement the strict + * {@link ALTER_COLUMN_TO_ENCRYPTED_RE} matched but skipped (`already-encrypted` + * or `source-unknown`) is left on disk unchanged, so it still contains + * `SET DATA TYPE` and the broad scan below finds it again. Without stripping + * the block comment here, that second pass reports a DIFFERENT statement string + * (comment glued to the front) than the strict pass's `match.trim()`, so + * {@link rewriteEncryptedAlterColumns}'s dedup key never matches and the same + * statement is reported twice — once with the correct reason, once as + * `unrecognised-form`, whose guidance (look for a hand-authored `USING`) is + * wrong for a statement the strict matcher already matched. Stripping the + * comment here makes both passes agree on the statement text so the second + * report collapses into the first. + * + * Known residue, accepted rather than fixed: a NESTED closed block comment + * ahead of a live ALTER (`/* outer /* inner *\/ still *\/`) still + * double-reports. The block-comment alternative's `*?` is lazy, so it stops + * at the FIRST `*\/` — consuming only `/* outer /* inner *\/` — and leaves + * `` still *\/`` glued to the front of the next iteration, which the + * line-comment alternative doesn't recognise either. That residual text rides + * along into the `unrecognised-form` report. + */ +const STATEMENT_PREAMBLE_RE = + /^(?:\s*\/\*[\s\S]*?\*\/|[^\S\n]*(?:--[^\n]*)?\n)*/ + +/** Drop the leading blank/comment lines a `[^;]*?`-anchored match dragged in. */ +function trimStatementPreamble(statement: string): string { + return statement.replace(STATEMENT_PREAMBLE_RE, '').trim() +} + +/** + * True when the character at `index` is INERT — it sits inside a SQL comment (a + * `--` line comment, or a nestable block comment) or inside a single-quoted + * string literal. Either way it is not a statement the database will execute, + * so the rewrite must leave it exactly as written. + * + * **Why the rewrite needs this:** {@link ALTER_COLUMN_TO_ENCRYPTED_RE} is + * comment-blind and {@link renderSafeAlter} returns MULTIPLE lines. Rewriting a + * commented-out `-- ALTER TABLE … SET DATA TYPE …;` therefore leaves the + * author's `-- ` prefix on line 1 only — lines 2+, including `DROP COLUMN`, + * become live executable SQL and destroy the column. Commented SQL is inert by + * definition, so the only correct move is to leave it exactly as written. + * + * **Why string literals count as inert too:** an ALTER quoted inside an + * `INSERT … VALUES ('…')` is DATA. Rewriting it splices `--> statement-breakpoint` + * markers INSIDE the literal, so splitting the file the way drizzle's migrator + * does yields a bare, live `ALTER TABLE … DROP COLUMN …;` as a chunk of its own. + * Escaping the injected text's own apostrophe (`@cipherstash/stack's`) would fix + * only the syntax error, not that live DROP COLUMN — so the statement is left + * as written, exactly like a commented one. + * + * Double-quoted identifiers are tokenised BEFORE `'` is considered: an + * apostrophe inside `"o'brien_data"` must not open a phantom literal, or the + * scan runs to the NEXT apostrophe — typically the same identifier in a + * commented-out ALTER further down — decides that ALTER is live, and rewrites + * it into a real `DROP COLUMN`. A doubled delimiter (`''` in a literal, `""` in + * an identifier) is an escape and does not close the token. + * + * Dollar-quoted bodies (`$$ … $$`, `$fn$ … $fn$`) are skipped whole, and an + * `E''` literal gives backslash its escape meaning. Both are quote-PARITY + * concerns, not merely "we might skip a rewrite": an apostrophe inside an + * untracked `$$` body, or a `\'` read as a close, ends a literal EARLY, and + * every token after it is then misread — including a commented-out ALTER, which + * reads as live and gets rewritten into a live DROP COLUMN. That is the same + * failure mode as the apostrophe-in-identifier case below, and it is why the + * earlier reasoning here ("can only make us skip") was wrong: it holds for an + * UNDER-terminated literal, not an over-terminated one (#772 review, finding 1). + */ +function isInsideCommentOrString(sql: string, index: number): boolean { + let i = 0 + while (i < index) { + if (sql.startsWith('--', i)) { + const eol = sql.indexOf('\n', i) + if (eol === -1 || eol >= index) return true + i = eol + 1 + } else if (sql.startsWith('/*', i)) { + // Postgres block comments nest, so track depth rather than stopping at + // the first `*/` — a nested close would otherwise end the comment early + // and let the text after it read as live SQL. + let depth = 1 + let j = i + 2 + while (j < sql.length && depth > 0) { + if (sql.startsWith('/*', j)) { + depth += 1 + j += 2 + } else if (sql.startsWith('*/', j)) { + depth -= 1 + j += 2 + } else { + j += 1 + } + } + if (j > index) return true + i = j + } else if (sql[i] === '$') { + // A `$tag$ … $tag$` body is opaque: its content is not SQL, so a `'` or + // `--` inside it means nothing. Skipping the whole body is what keeps the + // rest of the file's quote parity aligned with Postgres. + const delimiter = dollarQuoteDelimiter(sql, i) + if (delimiter === undefined) { + i += 1 + } else { + const close = sql.indexOf(delimiter, i + delimiter.length) + // Unterminated: inert to the end of the file, like the cases below. + const end = close === -1 ? sql.length : close + delimiter.length + if (end > index) return true + i = end + } + } else if (sql[i] === '"') { + // A quoted identifier is live SQL, but its body is not: consuming it here + // — before the `'` branch below — is what stops an apostrophe inside one + // from opening a string literal that never really existed. + const end = endOfQuoted(sql, i, '"') + // An unterminated identifier swallows the rest of the file, so treat it + // as inert exactly like the unterminated literal below. Running the + // cursor to the end instead would exit the loop and report `false` — + // "live" — which is how the apostrophe bug destroyed a column in the + // first place, one branch over. + if (end > index) return true + i = end + } else if (sql[i] === "'") { + const end = endOfQuoted(sql, i, "'", isEscapeStringOpener(sql, i)) + // Unterminated, or the literal runs past `index`: `index` is inside a + // string literal, which is every bit as inert as a comment. + if (end > index) return true + i = end + } else { + i += 1 + } + } + return false +} + +/** + * The `$tag$` delimiter opening at `open`, or `undefined` when what sits there + * is just a `$`. The tag is empty (`$$`) or an SQL identifier (`$fn$`); a digit + * cannot start one, so a positional parameter (`$1`) is never mistaken for a + * dollar-quote opener. + */ +function dollarQuoteDelimiter(sql: string, open: number): string | undefined { + DOLLAR_QUOTE_OPEN_RE.lastIndex = open + return DOLLAR_QUOTE_OPEN_RE.exec(sql)?.[0] +} + +/** Sticky, so the scan never has to slice the file to test one position. */ +const DOLLAR_QUOTE_OPEN_RE = /\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/y + +/** + * Whether the `'` at `open` starts an `E''` escape-string, in which a backslash + * escapes the following character. Plain literals give backslash no special + * meaning under the default `standard_conforming_strings = on`. + * + * The character before the `E` must not be part of an identifier, so a column + * or type name that merely ends in `e` does not turn the literal after it into + * an escape-string. + */ +function isEscapeStringOpener(sql: string, open: number): boolean { + const prefix = sql[open - 1] + if (prefix !== 'E' && prefix !== 'e') return false + return !/[A-Za-z0-9_]/.test(sql[open - 2] ?? '') +} + +/** + * The index just past the `quote`-delimited token that opens at `open`, or + * `sql.length` when it is never closed. A doubled delimiter inside the token is + * an escaped one (`''`, `""`) and does not end it. + * + * With `backslashEscapes`, a `\` also escapes the next character — the `E''` + * form. Getting this wrong ends the literal EARLY, which shifts quote parity + * for the whole rest of the file and can make a commented-out ALTER downstream + * read as live (#772 review, finding 1). + */ +function endOfQuoted( + sql: string, + open: number, + quote: "'" | '"', + backslashEscapes = false, +): number { + let i = open + 1 + while (i < sql.length) { + if (backslashEscapes && sql[i] === '\\') { + i += 2 + } else if (sql[i] !== quote) { + i += 1 + } else if (sql[i + 1] === quote) { + i += 2 + } else { + return i + 1 + } + } + return sql.length +} + +/** A table reference, bare (`"users"`) or schema-qualified (`"app"."users"`). */ +const TABLE_REF = String.raw`"([^"]+)"(?:\."([^"]+)")?` + +/** + * An encrypted type in any of the {@link MANGLED_TYPE_FORMS}, pinned to end at a + * delimiter so a bare domain cannot match a prefix of a longer identifier. + * + * Two shapes {@link MANGLED_TYPE_FORMS} does not enumerate are folded in here so + * the encrypted index recognises them and the ADD+DROP+RENAME cannot drop their + * ciphertext: a domain in ANY quoted schema (`"app"."eql_v3_text_search"`, not + * just the literal `public` the mangled forms special-case), and a `[` in the + * trailing lookahead so an ARRAY of the domain (`public.eql_v3_text_search[]`) + * ends at a delimiter too. Both feed only the encrypted index — never the + * rewrite matcher — so the worst case of admitting one is a flagged statement, + * the same safe asymmetry the fail-closed rule already relies on. + */ +const ENCRYPTED_TYPE_REF = String.raw`(?:${MANGLED_TYPE_FORMS}|"[^"]+"\."(?:${ENCRYPTED_DOMAIN})")(?=[\s,;)[]|$)` + +/** `ALTER TABLE … ADD COLUMN "col" ` — $1/$2 table, $3 column. */ +const ADD_ENCRYPTED_COLUMN_RE = new RegExp( + String.raw`ALTER TABLE\s+${TABLE_REF}\s+ADD COLUMN\s+(?:IF NOT EXISTS\s+)?"([^"]+)"\s+${ENCRYPTED_TYPE_REF}`, + 'gi', +) + +/** `ALTER TABLE … RENAME COLUMN "a" TO "b"` — $1/$2 table, $3 from, $4 to. */ +const RENAME_COLUMN_RE = new RegExp( + String.raw`ALTER TABLE\s+${TABLE_REF}\s+RENAME COLUMN\s+"([^"]+)"\s+TO\s+"([^"]+)"`, + 'gi', +) + +/** + * `CREATE TABLE … (` — $1/$2 table. The body's END is found by + * {@link endOfCreateTableBody} rather than by the matcher. + * + * This used to carry a lazy `([\s\S]*?)\)\s*;` body, which stopped at the FIRST + * `);` in the file — and that can sit inside a `--` comment or a string DEFAULT + * (`"id" text, -- pk (uuid);`). The body was then truncated and every column + * declared after that point vanished from both indexes, including an ENCRYPTED + * one — which is how a later domain change on a ciphertext column got past the + * already-encrypted guard (#772 review, finding 2). + */ +const CREATE_TABLE_HEAD_RE = new RegExp( + String.raw`CREATE TABLE\s+(?:IF NOT EXISTS\s+)?${TABLE_REF}\s*\(`, + 'gi', +) + +/** Candidate body terminators, filtered by {@link isInsideCommentOrString}. */ +const CREATE_TABLE_BODY_END_RE = /\)\s*;/g + +/** + * The offset of the `)` closing the CREATE TABLE body that opens at + * `bodyStart`, or `undefined` when the statement is never closed. + * + * Parens nested inside the body (`numeric(10,2)`, `CHECK (x > 0)`) are not + * followed by `;`, so requiring the terminator keeps them from matching. + */ +function endOfCreateTableBody( + sql: string, + bodyStart: number, +): number | undefined { + CREATE_TABLE_BODY_END_RE.lastIndex = bodyStart + for ( + let end = CREATE_TABLE_BODY_END_RE.exec(sql); + end !== null; + end = CREATE_TABLE_BODY_END_RE.exec(sql) + ) { + if (!isInsideCommentOrString(sql, end.index)) return end.index + } + return undefined +} + +/** `"col" ` inside a CREATE TABLE body — $1 column. */ +const CREATE_TABLE_ENCRYPTED_COLUMN_RE = new RegExp( + String.raw`"([^"]+)"\s+${ENCRYPTED_TYPE_REF}`, + 'gi', +) + +/** + * A column name in a DECLARATION position — `"email" text`, + * `"email" "public"."eql_v3_text_search"`, `"amount" numeric(10, 2)`. + * + * The `\s+["a-z]` tail is what separates a declaration from a MENTION. Most + * mentions are followed by `)`, `,`, or an operator, which the tail alone + * already rejects: + * + * ```sql + * PRIMARY KEY ("id", "name") + * FOREIGN KEY ("org_id") REFERENCES "orgs"("id") + * ``` + * + * But a mention inside a predicate or a table-level constraint is often + * followed by whitespace and a SQL KEYWORD — which has exactly the same + * `\s+[a-z]` shape as a real declaration, and drizzle-kit emits both forms + * inside a `CREATE TABLE` (a `CONSTRAINT … CHECK ()` body is + * user-authored, so the predicate form is reachable too): + * + * ```sql + * CHECK ("email" IS NOT NULL) + * CONSTRAINT "users_email_unique" UNIQUE ("email") + * ``` + * + * The negative lookahead rejects the keywords reachable this way: predicate + * keywords (`IS`, `IN`, `NOT`, `LIKE`, `ILIKE`, `BETWEEN`, `AND`, `OR`), + * ordering/collation (`COLLATE`, `ASC`, `DESC`, `NULLS`), and every + * table/column-constraint keyword (`UNIQUE`, `PRIMARY`, `FOREIGN`, `CHECK`, + * `EXCLUDE`, `REFERENCES`, `CONSTRAINT`, `USING`, `WITH`) — the last six also + * close the constraint-NAME case above, since Postgres always puts one of + * them immediately after a constraint's name. + * + * A real type token still matches because the lookahead is pinned to a word + * boundary (`\b`) right after each keyword, so it only rejects a keyword when + * that keyword is the WHOLE next word: `interval` and `inet` both start with + * `IN`'s letters, but their third character keeps them inside one word, so + * `\bIN\b` never matches there; the same reasoning clears `int`, + * `char`/`citext` against `CHECK`, and `eql_v2_encrypted`/`eql_v3_*` against + * `EXCLUDE`. + * + * Deliberately NOT pinned to the encrypted domains. A column the corpus + * declares but does not give an encrypted type is, by residue, plaintext — so + * the fail-closed rule needs no type classification and no SQL parsing, only + * the known encrypted list that {@link ENCRYPTED_TYPE_REF} already provides. + * + * **The residue claim depends on {@link ENCRYPTED_TYPE_REF} recognising every + * encrypted shape.** "By residue, plaintext" is only as good as the encrypted + * side's coverage: a declaration {@link ENCRYPTED_TYPE_REF} fails to recognise + * falls to the plaintext residue and gets rewritten. Two such shapes are now + * covered explicitly by {@link ENCRYPTED_TYPE_REF} — a domain in a non-`public` + * schema (`"email" "app"."eql_v3_text_search"`) and an array of the domain + * (`"email" public.eql_v3_text_search[]`) — because both name ciphertext the + * corpus can see, so rewriting them is the exact drop this rule exists to + * prevent. The dependency itself remains: any encrypted shape a future EQL + * install introduces must be added to {@link ENCRYPTED_TYPE_REF} or it too will + * fall to the plaintext residue. + * + * **Residue, accepted.** The lookahead is a fixed keyword list, not a parser: + * a predicate keyword it does not enumerate (`SIMILAR`, `ISNULL`, `NOTNULL`, + * `OVERLAPS`, `ON`, …) still lets a bare mention through, e.g. + * `CHECK ("email" SIMILAR TO '...')`, or a `REFERENCES "email" ON DELETE + * CASCADE` where `"email"` names the referenced TABLE rather than the column + * being declared — `"id" integer REFERENCES "email" ON DELETE CASCADE` inside + * a `CREATE TABLE` body still registers `email` as declared. That one only + * bites when a column shares a referenced table's name, so like the rest of + * this residue it is inert unless that mention's name exactly matches a + * DIFFERENT column that is both encrypted and genuinely undeclared anywhere + * else in the corpus — contrived, and strictly narrower than the gap this + * replaces. + */ +const DECLARED_COLUMN_RE = + /"([^"]+)"\s+(?!(?:IS|IN|NOT|LIKE|ILIKE|BETWEEN|AND|OR|COLLATE|ASC|DESC|NULLS|UNIQUE|PRIMARY|FOREIGN|CHECK|EXCLUDE|REFERENCES|CONSTRAINT|USING|WITH)\b)["a-z]/gi + +/** + * `ALTER TABLE … ADD COLUMN "col" ` — $1/$2 table, $3 column. + * + * Needs no {@link DECLARED_COLUMN_RE}-style keyword lookahead: the token + * right after an ADD COLUMN's column name is always its type, so no SQL + * keyword can occupy that position. + */ +const ADD_COLUMN_RE = new RegExp( + String.raw`ALTER TABLE\s+${TABLE_REF}\s+ADD COLUMN\s+(?:IF NOT EXISTS\s+)?"([^"]+)"\s+["a-z]`, + 'gi', +) + +/** Splits a `TABLE_REF` capture pair into its schema and table halves. */ +function tableOf( + first: string, + second: string | undefined, +): { schema?: string; table: string } { + // When schema-qualified (`"app"."users"`) the first capture is the schema and + // the second is the table; otherwise the first is the table. + return second === undefined + ? { table: first } + : { schema: first, table: second } +} + +/** + * Identity of a column across the corpus, for {@link indexColumnDeclarations}. + * + * `public` and no schema at all are the SAME table: Postgres resolves an + * unqualified name through `search_path`, which starts at `public`. The two + * spellings coexist in one corpus routinely — drizzle-kit emits unqualified, + * while hand-written SQL and this sweep's own {@link renderSafeAlter} output + * are qualified. Keying on the literal text split one column across two keys, + * so a column already encrypted under one spelling looked plaintext when + * altered under the other and the ADD+DROP+RENAME dropped its ciphertext + * (#772 review, finding 4). + * + * Only the IMPLICIT schema collapses. `"app"."users"` is a genuinely different + * table from `"users"` and keeps its own key. No case folding either: + * `TABLE_REF` matches quoted identifiers only, and those are case-sensitive in + * Postgres — `"PUBLIC"` really is a different schema from `"public"`. + */ +function columnKey(table: string, column: string, schema?: string): string { + return JSON.stringify([ + schema === 'public' ? '' : (schema ?? ''), + table, + column, + ]) +} + +/** What the migration corpus says about the columns it mentions. */ +interface ColumnIndex { + /** Columns the corpus gives an ENCRYPTED type. Rewriting one drops ciphertext. */ + encrypted: Set + /** Columns the corpus DECLARES at all, whatever type it gives them. */ + declared: Set +} + +/** + * Index what the migration corpus knows about each column, so the rewrite can + * tell the change it exists for (plaintext → encrypted) from the two it must + * never make: encrypted → encrypted, and a change to a column it knows nothing + * about. + * + * **Why `encrypted` (#772 review, W-3):** the strict matcher captures only the + * TARGET type. A column whose encrypted domain merely changes (`types.TextEq` → + * `types.TextSearch`) matches just as well as a plaintext column, and the + * ADD+DROP+RENAME then drops a column full of CIPHERTEXT — with no plaintext + * left anywhere to backfill from, so unlike the plaintext case the data is not + * recoverable from the application at all. + * + * **Why `declared` (A-2):** absence from `encrypted` is not evidence of + * plaintext. It is evidence of nothing. The sweep runs per directory, and the + * shipped wizard default scans three of them, so a column's `CREATE TABLE` can + * simply live somewhere this sweep never reads — the column is then rewritten + * on an assumption, and the ADD+DROP+RENAME drops live ciphertext. Requiring a + * POSITIVE declaration makes the unknown case a flagged statement instead. + * + * Because the encrypted side is matched against the known domain list, the + * plaintext side needs no type classification: a column the corpus declares but + * does not give an encrypted type is plaintext by residue. + * + * The index is corpus-wide rather than ordered by migration: over-detecting + * "encrypted" costs a flagged statement the user handles by hand, while + * under-detecting costs irrecoverable ciphertext. That asymmetry is why the + * two per-column scans inside a `CREATE TABLE` body are deliberately + * inconsistent about comments (see the comment at the loops themselves) — + * do not "fix" that inconsistency, it is the point. + */ +function indexColumnDeclarations(contents: readonly string[]): ColumnIndex { + const encrypted = new Set() + const declared = new Set() + + for (const sql of contents) { + for (const created of sql.matchAll(CREATE_TABLE_HEAD_RE)) { + if (isInsideCommentOrString(sql, created.index)) continue + const { schema, table } = tableOf(created[1], created[2]) + const bodyStart = created.index + created[0].length + const bodyEnd = endOfCreateTableBody(sql, bodyStart) + // Never closed — treat the statement as absent rather than index a body + // that runs to the end of the file. + if (bodyEnd === undefined) continue + const body = sql.slice(bodyStart, bodyEnd) + + // The body is scanned as its own document: a `--` earlier in it comments + // out the rest of that line, and a CREATE inside a block comment or a + // string literal was already skipped above. + // + // Asymmetric on purpose. ENCRYPTED does NOT re-check comments here: a + // commented-out encrypted column inside an otherwise live CREATE TABLE + // still counts, exactly as it did before this sweep learned to declare + // anything — over-detecting "encrypted" only costs a flagged statement. + // DECLARED DOES re-check: a commented-out plaintext line never ran, so + // it must not count as a declaration — over-detecting "declared" is + // what would put a truly-undeclared, possibly already-encrypted column + // back on the fail-open path. + for (const column of body.matchAll(CREATE_TABLE_ENCRYPTED_COLUMN_RE)) { + encrypted.add(columnKey(table, column[1], schema)) + } + + for (const column of body.matchAll(DECLARED_COLUMN_RE)) { + if (isInsideCommentOrString(body, column.index)) continue + declared.add(columnKey(table, column[1], schema)) + } + } + + for (const added of sql.matchAll(ADD_ENCRYPTED_COLUMN_RE)) { + if (isInsideCommentOrString(sql, added.index)) continue + const { schema, table } = tableOf(added[1], added[2]) + encrypted.add(columnKey(table, added[3], schema)) + } + + for (const added of sql.matchAll(ADD_COLUMN_RE)) { + if (isInsideCommentOrString(sql, added.index)) continue + const { schema, table } = tableOf(added[1], added[2]) + declared.add(columnKey(table, added[3], schema)) + } + + // A rename carries the column's type with it — and `__cipherstash_tmp` + // renamed onto the real name is exactly what a previous sweep of this very + // directory emitted. Run after ADD so that tmp column is already indexed. + for (const renamed of sql.matchAll(RENAME_COLUMN_RE)) { + if (isInsideCommentOrString(sql, renamed.index)) continue + const { schema, table } = tableOf(renamed[1], renamed[2]) + const from = columnKey(table, renamed[3], schema) + const to = columnKey(table, renamed[4], schema) + if (encrypted.has(from)) encrypted.add(to) + if (declared.has(from)) declared.add(to) + } + } + + return { encrypted, declared } +} + +/** Why a recognised ALTER-to-encrypted statement was left alone. */ +export type SkipReason = + /** Outside the strict matcher — hand-authored `USING`, or an unknown form. */ + | 'unrecognised-form' + /** The column already holds an encrypted domain; rewriting drops ciphertext. */ + | 'already-encrypted' + /** The corpus never declares the column, so its current type is unknown. */ + | 'source-unknown' + /** A statement the sweep recognised as ALTER-to-encrypted but did NOT rewrite. */ export interface SkippedAlter { /** Absolute path of the migration file the statement lives in. */ file: string /** The offending statement, verbatim (trimmed), for the user to review. */ statement: string + /** Why it was left alone — the caller turns this into user-facing guidance. */ + reason: SkipReason +} + +/** + * One-line explanation of a {@link SkipReason}, for the CLI/wizard to print + * next to the statement. Lives here so every caller says the same thing — the + * three reasons need very different action from the user, and a single generic + * "could not rewrite automatically" hides that. + */ +export function describeSkipReason(reason: SkipReason): string { + switch (reason) { + case 'already-encrypted': + return "the column is ALREADY encrypted, so the ADD+DROP+RENAME rewrite would DROP the ciphertext with no plaintext left to backfill from. Changing an encrypted column's domain changes its index terms, so the data must be re-encrypted through the staged `stash encrypt` lifecycle" + case 'unrecognised-form': + return 'it falls outside the strict matcher (a hand-authored `SET DATA TYPE ... USING ...`, or a drizzle-kit form the sweep does not recognise) and an in-place cast to an encrypted domain fails at migrate time' + case 'source-unknown': + return "the sweep could not find where this column was declared in this migration directory, so it cannot tell a plaintext column (safe to rewrite) from one that already holds ciphertext (where the rewrite would DROP it). Usually the column's `CREATE TABLE` / `ADD COLUMN` lives in a different directory, or the migration history was squashed. Check the column's current type in the database: if it is plaintext and the table is empty, apply the ADD/DROP/RENAME by hand; if it already holds ciphertext, use the staged `stash encrypt` lifecycle instead" + } } /** Outcome of a sweep: the files rewritten, and near-misses left for review. */ @@ -133,27 +680,62 @@ export interface RewriteResult { * which keeps both columns alive across deploys. Each rewritten file carries a * header comment saying exactly this. * - * Returns {@link RewriteResult}: the files rewritten, plus `skipped` near-misses - * — statements that look like an ALTER-to-encrypted but fall outside the strict - * matcher (a hand-authored `SET DATA TYPE … USING …;`, or a future drizzle-kit - * form). Near-misses are left untouched on disk and surfaced non-fatally so the - * caller can tell the user to review them, rather than silently shipping broken - * SQL. + * Returns {@link RewriteResult}: the files rewritten, plus `skipped` statements + * left for a human — ones outside the strict matcher (a hand-authored + * `SET DATA TYPE … USING …;`, or a future drizzle-kit form); ones targeting a + * column that is ALREADY encrypted, where the rewrite would drop ciphertext; + * and ones targeting a column the corpus never DECLARES anywhere, where the + * rewrite would be guessing at a type it cannot see (likely the most common + * skip on a real corpus, since the sweep only ever reads part of the + * migration history). All three are left untouched on disk and surfaced + * non-fatally so the caller can tell the user to review them, rather than + * silently shipping broken SQL or destroying data. Statements sitting inside a + * SQL comment — or inside a single-quoted string literal, where they are data + * rather than SQL — are inert and are neither rewritten nor reported. */ export async function rewriteEncryptedAlterColumns( outDir: string, options: { skip?: string } = {}, ): Promise { - const entries = await readdir(outDir).catch(() => []) + const entries = await readdir(outDir).catch( + (error: NodeJS.ErrnoException) => { + // A missing directory is simply nothing to sweep. Anything else — EACCES + // above all — is a sweep that did NOT happen, and the caller reports it + // rather than letting the user believe their migrations were checked. + if (error.code === 'ENOENT') return [] as string[] + throw error + }, + ) const rewritten: string[] = [] const skipped: SkippedAlter[] = [] + const seen = new Set() + + /** Record a skip once — the strict pass and the broad scan can both find it. */ + const skip = (file: string, statement: string, reason: SkipReason): void => { + // Keyed on collapsed whitespace: the two passes trim the same statement + // by slightly different rules, and the strict pass runs first so its + // more specific reason is the one kept. + const key = `${file} :: ${statement.replace(/\s+/g, ' ')}` + if (seen.has(key)) return + seen.add(key) + skipped.push({ file, statement, reason }) + } - for (const entry of entries) { - if (!entry.endsWith('.sql')) continue + const sqlFiles = entries.filter((entry) => entry.endsWith('.sql')).sort() + const contents = new Map() + for (const entry of sqlFiles) { const filePath = join(outDir, entry) - if (options.skip && filePath === options.skip) continue + contents.set(filePath, await readFile(filePath, 'utf-8')) + } + + // Built from the WHOLE corpus, including `options.skip`: a column's current + // type comes from the migrations that ran before this one, not just the files + // we are allowed to edit. + const { encrypted: encryptedColumns, declared: declaredColumns } = + indexColumnDeclarations([...contents.values()]) - const original = await readFile(filePath, 'utf-8') + for (const [filePath, original] of contents) { + if (options.skip && filePath === options.skip) continue // Reset the regex's lastIndex — it's stateful on /g ALTER_COLUMN_TO_ENCRYPTED_RE.lastIndex = 0 @@ -166,15 +748,54 @@ export async function rewriteEncryptedAlterColumns( second: string | undefined, column: string, mangledType: string, + offset: number, ) => { - // When schema-qualified (`"app"."users"`) the first capture is the - // schema and the second is the table; otherwise the first is the table. - const schema = second === undefined ? undefined : first - const table = second === undefined ? first : second + // Commented-out SQL never runs, and a multi-line replacement would only + // inherit the `-- ` on its first line — leaving the rest live. + if (isInsideCommentOrString(original, offset)) return match + + const { schema, table } = tableOf(first, second) + + // Already encrypted: the ADD+DROP+RENAME would drop the ciphertext and + // there is no plaintext left to backfill from. Flag, never guess. + if (encryptedColumns.has(columnKey(table, column, schema))) { + skip(filePath, match.trim(), 'already-encrypted') + return match + } + + // Fail closed. Absence from `declaredColumns` does not mean the column + // is plaintext — it means the corpus never said. The declaration can + // sit in a directory this sweep does not read (the wizard ships with + // three candidates and indexes each separately), so rewriting on the + // assumption is how a live `DROP COLUMN` reaches a populated — possibly + // already-encrypted — column. Flag it and let the user look. + if (!declaredColumns.has(columnKey(table, column, schema))) { + skip(filePath, match.trim(), 'source-unknown') + return match + } + const domain = DOMAIN_RE.exec(mangledType)?.[0]?.toLowerCase() // Unreachable — the outer regex only matches when a domain is present — // but leave the statement alone rather than emit a broken rewrite. if (!domain) return match + + // This statement converts the column, so from here on in the corpus it + // holds CIPHERTEXT. `indexColumnDeclarations` cannot know that: it + // reads CREATE TABLE, ADD COLUMN and RENAME, never the strict matcher's + // own target. Without this, a corpus carrying an earlier conversion + // (`... SET DATA TYPE eql_v2_encrypted` from a stack version that + // predates this sweep) leaves the column looking plaintext, and the + // NEXT domain change drops the ciphertext — `declared` cannot catch it, + // because the column really is declared, as plaintext, by the original + // CREATE TABLE. + // + // Recorded here rather than in the corpus-wide index on purpose: the + // index has no order, so it would flag THIS conversion — the legitimate + // plaintext -> encrypted one — as already-encrypted too. Files are + // walked in sorted order and matches within a file in source order, so + // "already converted" means "converted by a statement that runs before + // this one". + encryptedColumns.add(columnKey(table, column, schema)) return renderSafeAlter(table, column, domain, schema) }, ) @@ -189,7 +810,16 @@ export async function rewriteEncryptedAlterColumns( // matcher. Flag it — non-fatally — rather than leave the user shipping SQL // that fails at migrate time. for (const nearMiss of updated.matchAll(NEAR_MISS_RE)) { - skipped.push({ file: filePath, statement: nearMiss[0].trim() }) + const statement = trimStatementPreamble(nearMiss[0]) + // Anchor the comment test on the `SET DATA TYPE` itself: the match starts + // at the previous `;`, so its own offset sits before any preamble. + const keyword = nearMiss[0].search(/\bSET\s+DATA\s+TYPE\b/i) + if ( + isInsideCommentOrString(updated, nearMiss.index + Math.max(keyword, 0)) + ) { + continue + } + skip(filePath, statement, 'unrecognised-form') } } diff --git a/packages/cli/src/commands/encrypt/__tests__/context-placeholder.test.ts b/packages/cli/src/commands/encrypt/__tests__/context-placeholder.test.ts new file mode 100644 index 000000000..9a0ad2440 --- /dev/null +++ b/packages/cli/src/commands/encrypt/__tests__/context-placeholder.test.ts @@ -0,0 +1,170 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { PLACEHOLDER_TABLE_NAME } from '@/config/index.js' + +/** + * `stash encrypt` does not load its client through `loadEncryptConfig`, so the + * placeholder guard that command group needs lives in `loadEncryptionContext`. + * Both must refuse the un-replaced scaffold; otherwise `requireTable` reports + * `Table "users" was not found … Available: __stash_placeholder__`, which names + * the symptom instead of the cause (#787 review). + * + * Runs against the real jiti runtime — the guard reads the tables harvested + * from an actually-evaluated client module, which is the part worth pinning. + */ +describe('loadEncryptionContext — the un-replaced init scaffold', () => { + let tmpDir: string + let originalCwd: () => string + + const writeProject = (clientBody: string) => { + fs.writeFileSync( + path.join(tmpDir, 'stash.config.ts'), + `export default { + databaseUrl: 'postgresql://u:p@127.0.0.1:5432/db', + client: './client.ts', + }`, + ) + fs.writeFileSync(path.join(tmpDir, 'client.ts'), clientBody) + process.cwd = () => tmpDir + } + + /** + * The duck-typed shapes `loadEncryptionContext` harvests: any export with a + * `getEncryptConfig()` method is the client, and any with `tableName` + + * `build()` is a table. Hand-rolled rather than imported from + * `@cipherstash/stack` so the test needs no native module. + */ + const table = (name: string) => + `{ tableName: '${name}', build: () => ({ tableName: '${name}', columns: {} }) }` + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stash-encrypt-ctx-')) + originalCwd = process.cwd + }) + + afterEach(() => { + process.cwd = originalCwd + vi.restoreAllMocks() + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('exits 1 naming the sentinel when it is the only table declared', async () => { + // The scaffold's real shape: the sentinel is both exported AND the sole + // entry in the built encrypt config, because it was passed to `Encryption`. + writeProject( + `export const encryptionClient = { + getEncryptConfig: () => ({ tables: { '${PLACEHOLDER_TABLE_NAME}': {} } }), + } + export const placeholderTable = ${table(PLACEHOLDER_TABLE_NAME)}`, + ) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit') + }) + + const { loadEncryptionContext } = await import('../context.js') + await expect(loadEncryptionContext()).rejects.toThrow('process.exit') + + const message = error.mock.calls.flat().join('\n') + expect(message).toContain(PLACEHOLDER_TABLE_NAME) + // Names the cause, not `requireTable`'s "table not found" symptom. + expect(message).toContain('still contains the placeholder table') + expect(message).not.toContain('was not found in the encryption client') + }) + + /** + * #787 review. The guard originally read the harvested EXPORT map, while the + * `db push` / `db validate` guard it mirrors reads `getEncryptConfig().tables`. + * Those disagree in both directions on the same client file, so the two + * commands gave different answers for identical input. + */ + it('fires when the placeholder is passed to Encryption but never exported', async () => { + // The false NEGATIVE. `schemas: [placeholderTable]` with a bare `const` — + // the scaffold minus one `export` keyword. Reading exports, the guard saw + // no tables and fell through to `requireTable`'s "table not found … + // Available: (none)" — the very error this guard exists to replace. + writeProject( + `export const encryptionClient = { + getEncryptConfig: () => ({ tables: { '${PLACEHOLDER_TABLE_NAME}': {} } }), + }`, + ) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit') + }) + + const { loadEncryptionContext } = await import('../context.js') + await expect(loadEncryptionContext()).rejects.toThrow('process.exit') + + const message = error.mock.calls.flat().join('\n') + expect(message).toContain('still contains the placeholder table') + expect(message).not.toContain('was not found in the encryption client') + }) + + it('stays silent when a stale placeholder export sits beside real configured tables', async () => { + // The false POSITIVE, and the mirror of the case above: the user replaced + // the schema set but left the sentinel `export` behind (or imports their + // real tables without re-exporting them). Reading exports, the guard fired + // and told them to declare columns they had already declared. `db push` + // passes on this same file. + writeProject( + `export const encryptionClient = { + getEncryptConfig: () => ({ tables: { users: {} } }), + } + export const placeholderTable = ${table(PLACEHOLDER_TABLE_NAME)}`, + ) + + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const exit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit') + }) + + const { loadEncryptionContext } = await import('../context.js') + const ctx = await loadEncryptionContext() + + // The silence this test is named for. Without the `exit` spy the guard + // firing here would kill the worker instead of failing an assertion. + expect(exit).not.toHaveBeenCalled() + expect(error.mock.calls.flat().join('\n')).not.toContain( + 'still contains the placeholder table', + ) + expect(ctx.tables.has(PLACEHOLDER_TABLE_NAME)).toBe(true) + }) + + it('allows the sentinel through once a real table sits alongside it', async () => { + // Only the SOLE-placeholder case is the un-replaced scaffold. A user who + // has added real tables must not be blocked by a leftover sentinel. + // + // The sentinel is declared FIRST in the config deliberately: the guard + // reads `configuredTables[0]`, so a placeholder-last fixture passes even + // with the arity check deleted. Ordered this way, dropping + // `configuredTables.length === 1` makes this test fail — which is the only + // thing that pins that clause anywhere in the suite (#787 review follow-up). + writeProject( + `export const encryptionClient = { + getEncryptConfig: () => ({ + tables: { '${PLACEHOLDER_TABLE_NAME}': {}, users: {} }, + }), + } + export const placeholderTable = ${table(PLACEHOLDER_TABLE_NAME)} + export const users = ${table('users')}`, + ) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const exit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit') + }) + + const { loadEncryptionContext } = await import('../context.js') + const ctx = await loadEncryptionContext() + + expect(exit).not.toHaveBeenCalled() + expect(error.mock.calls.flat().join('\n')).not.toContain( + 'still contains the placeholder table', + ) + expect(ctx.tables.has('users')).toBe(true) + }) +}) diff --git a/packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts b/packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts index da2389aff..2b8255e79 100644 --- a/packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts +++ b/packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts @@ -1,4 +1,9 @@ +import type { + EncryptedColumnInfo, + ResolvedEncryptedColumn, +} from '@cipherstash/migrate' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ResolvedLifecycle } from '../lib/resolve-eql.js' // The EQL-v3 lifecycle branches (#648/#649): v3 has no cut-over rename — // `encrypt cutover` must short-circuit with guidance instead of running the @@ -23,9 +28,14 @@ vi.mock('pg', () => ({ }, })) -type ColumnInfo = { column: string; domain: string; version: 2 | 3 } -type ResolvedInfo = ColumnInfo & { via: 'hint' | 'convention' | 'sole' } -type Lifecycle = { info: ResolvedInfo | null; candidates: ColumnInfo[] } +// Imported, never re-declared. These stubs stand in for the real +// `resolveColumnLifecycle`, so a structural copy would let a renamed or added +// field compile on both sides while the commands read something the resolver no +// longer returns — the exact seam `v2-lifecycle-composition.integration.test.ts` +// exercises at runtime, pinned here at compile time (#787 review follow-up). +type ColumnInfo = EncryptedColumnInfo +type ResolvedInfo = ResolvedEncryptedColumn +type Lifecycle = ResolvedLifecycle const migrateMocks = vi.hoisted(() => ({ listEncryptedColumns: vi.fn(async (): Promise => []), @@ -216,6 +226,36 @@ describe('encrypt cutover — EQL version awareness', () => { ) }) + // #772 review, finding 7. `drop` gated on `via === 'sole'`; `cutover` did + // not, and its v3 branch `return`s without setting exitCode — so a mixed + // table (a v2 pair the classifier no longer sees, plus one unrelated v3 + // column) produced a success-shaped message and exit 0 while the v2 rename + // never ran. A scripted rollout read that as done. + it("refuses a by-elimination ('sole') match rather than reporting success", async () => { + lifecycleMock.mockResolvedValue( + resolved( + { column: 'email_enc', domain: 'eql_v3_text_search', version: 3 }, + 'sole', + ), + ) + migrateMocks.progress.mockResolvedValue({ phase: 'backfilled' }) + const exitSpy = spyExit() + + await cutoverCommand({ table: 'users', column: 'ssn' }) + + expect(p.log.error).toHaveBeenCalledWith( + expect.stringContaining('nothing confirms it encrypts "ssn"'), + ) + // The old message told the user to point their application at the guessed + // column, as though the lifecycle were complete. + expect(p.log.info).not.toHaveBeenCalledWith( + expect.stringContaining('point your application at email_enc'), + ) + expect(migrateMocks.renameEncryptedColumns).not.toHaveBeenCalled() + expect(exitSpy).toHaveBeenCalledWith(1) + exitSpy.mockRestore() + }) + it('fails closed when EQL columns exist but none is identifiable', async () => { lifecycleMock.mockResolvedValue({ info: null, @@ -372,9 +412,21 @@ describe('encrypt drop — EQL version awareness', () => { expect(p.log.error).toHaveBeenCalledWith( expect.stringContaining('nothing confirms it encrypts "email"'), ) - expect(p.log.error).toHaveBeenCalledWith( + // The remedy must not prescribe the GUESS. Recording `secret_blob` makes + // the next resolution `via: 'hint'`, which walks past this very gate — and + // the coverage check then passes vacuously, because an unrelated but + // legitimately-backfilled column is non-NULL on every row. Following that + // advice generated a live DROP COLUMN on the plaintext at exit 0 + // (#772 review, finding 7). + expect(p.log.error).not.toHaveBeenCalledWith( expect.stringContaining('--encrypted-column secret_blob'), ) + expect(p.log.error).toHaveBeenCalledWith( + expect.stringContaining('--encrypted-column '), + ) + expect(p.log.error).toHaveBeenCalledWith( + expect.stringContaining('do not record secret_blob'), + ) expect(migrateMocks.countUnencrypted).not.toHaveBeenCalled() expect(writeFileMock).not.toHaveBeenCalled() expect(exitSpy).toHaveBeenCalledWith(1) @@ -430,10 +482,14 @@ describe('encrypt drop — EQL version awareness', () => { // After cutover renamed the ciphertext onto `email`, no counterpart is // resolvable BY DESIGN. The fail-closed guard must recognize this state // rather than blocking the one drop the lifecycle actually wants. - lifecycleMock.mockResolvedValue({ - info: null, - candidates: [{ column: 'email', domain: 'eql_v2_encrypted', version: 2 }], - }) + // + // `candidates` is EMPTY, not `[{ column: 'email', version: 2 }]`: the + // classifier no longer recognises `eql_v2_encrypted`, so a post-cutover v2 + // column drops out of `listEncryptedColumns` entirely. The state reaches + // `explainUnresolved` as "no EQL columns at all", which is exactly the + // case it already falls through on. This pins that the v2 lifecycle still + // works through the narrower classifier. + lifecycleMock.mockResolvedValue({ info: null, candidates: [] }) migrateMocks.progress.mockResolvedValueOnce({ phase: 'cut-over' }) await dropCommand({ table: 'users', column: 'email' }) diff --git a/packages/cli/src/commands/encrypt/__tests__/prisma-next-context.test.ts b/packages/cli/src/commands/encrypt/__tests__/prisma-next-context.test.ts new file mode 100644 index 000000000..ccd787ba7 --- /dev/null +++ b/packages/cli/src/commands/encrypt/__tests__/prisma-next-context.test.ts @@ -0,0 +1,227 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Prisma Next projects have no hand-authored encryption client file — the +// schema lives in the emitted contract.json (#). `loadEncryptionContext` +// must derive the v3 schemas from the contract (mirroring the runtime's +// `cipherstashFromStack`) instead of hard-failing on the missing file, and +// `backfillCommand` must create `cipherstash.cs_migrations` itself because the +// Prisma Next EQL install path never runs `stash eql install`. + +const fsMocks = vi.hoisted(() => ({ + existsSync: vi.fn((_p: string) => false), + readFileSync: vi.fn((_p: string) => '{}'), +})) +vi.mock('node:fs', () => ({ default: fsMocks })) + +const detectPrismaNextMock = vi.hoisted(() => vi.fn(() => false)) +vi.mock('@/commands/db/detect.js', () => ({ + detectPrismaNext: detectPrismaNextMock, + detectDrizzle: vi.fn(() => false), +})) + +const requireUsableEncryptConfigMock = vi.hoisted(() => + vi.fn((config: unknown) => config), +) +vi.mock('@/config/index.js', () => ({ + loadStashConfig: vi.fn(async () => ({ + databaseUrl: 'postgres://test', + client: './src/encryption/index.ts', + })), + requireUsableEncryptConfig: requireUsableEncryptConfigMock, +})) + +vi.mock('@/commands/init/utils.js', () => ({ + detectPackageManager: vi.fn(() => 'npm'), + runnerCommand: vi.fn((_pm: string, ref: string) => `npx ${ref}`), +})) + +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + outro: vi.fn(), + confirm: vi.fn(async () => true), + isCancel: vi.fn(() => false), + log: { + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + step: vi.fn(), + }, + note: vi.fn(), +})) + +const encryptionClientStub = vi.hoisted(() => ({ + getEncryptConfig: vi.fn(() => ({ v: 2, tables: {} })), +})) +const deriveStackSchemasV3Mock = vi.hoisted(() => + vi.fn((_contract: unknown): unknown[] => []), +) +const encryptionV3Mock = vi.hoisted(() => + vi.fn(async () => encryptionClientStub), +) +const jitiImportMock = vi.hoisted(() => + vi.fn(async (specifier: string) => { + if (specifier === '@cipherstash/prisma-next/v3') { + return { deriveStackSchemasV3: deriveStackSchemasV3Mock } + } + if (specifier === '@cipherstash/stack/v3') { + return { EncryptionV3: encryptionV3Mock } + } + throw new Error(`unexpected jiti import: ${specifier}`) + }), +) +vi.mock('jiti', () => ({ + createJiti: vi.fn(() => ({ import: jitiImportMock })), +})) + +const queryMock = vi.hoisted(() => + vi.fn(async (_sql: string) => ({ rows: [] as unknown[] })), +) +vi.mock('pg', () => ({ + default: { + Pool: class { + connect = vi.fn(async () => ({ query: queryMock, release: vi.fn() })) + end = vi.fn(async () => {}) + }, + }, +})) + +// The workspace `@cipherstash/stack` package resolves via its built dist in +// CI; mock the `schema` subpath with a zod enum matching the shape +// `translateCastAs` needs so this test doesn't depend on a prior build. +vi.mock('@cipherstash/stack/schema', async () => { + const { z } = await import('zod') + return { + castAsEnum: z.enum(['string', 'text', 'number', 'bigint']).default('text'), + toEqlCastAs: vi.fn((v: string) => v), + } +}) + +const migrateMocks = vi.hoisted(() => ({ + appendEvent: vi.fn(async () => {}), + detectColumnEqlVersion: vi.fn(async () => 3), + installMigrationsSchema: vi.fn(async () => {}), + progress: vi.fn(async () => ({ phase: 'dual-writing' })), + runBackfill: vi.fn(async () => ({ rowsProcessed: 0, rowsTotal: 0 })), + upsertManifestColumn: vi.fn(async () => {}), +})) +vi.mock('@cipherstash/migrate', () => migrateMocks) + +import { loadEncryptionContext } from '../context.js' + +const FAKE_TABLE = { + tableName: 'transaction', + build: () => ({ + tableName: 'transaction', + columns: { email_encrypted: { cast_as: 'text' } }, + }), +} + +/** process.exit that throws, so exit paths terminate the code under test. */ +function spyExit() { + return vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code}`) + }) as never) +} + +function spyConsoleError() { + return vi.spyOn(console, 'error').mockImplementation(() => {}) +} + +beforeEach(() => { + vi.clearAllMocks() + fsMocks.existsSync.mockImplementation(() => false) + fsMocks.readFileSync.mockImplementation(() => '{}') + detectPrismaNextMock.mockReturnValue(false) + deriveStackSchemasV3Mock.mockReturnValue([]) +}) + +describe('loadEncryptionContext — Prisma Next contract derivation', () => { + it('still hard-fails on a missing client file outside Prisma Next projects', async () => { + const exit = spyExit() + const consoleError = spyConsoleError() + + await expect(loadEncryptionContext()).rejects.toThrow('process.exit:1') + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('Encrypt client file not found'), + ) + expect(jitiImportMock).not.toHaveBeenCalled() + + exit.mockRestore() + consoleError.mockRestore() + }) + + it('derives tables and client from contract.json in a Prisma Next project', async () => { + detectPrismaNextMock.mockReturnValue(true) + fsMocks.existsSync.mockImplementation((p: string) => + p.endsWith('src/prisma/contract.json'), + ) + fsMocks.readFileSync.mockReturnValue('{"storage":{}}') + deriveStackSchemasV3Mock.mockReturnValue([FAKE_TABLE]) + + const ctx = await loadEncryptionContext() + + expect(deriveStackSchemasV3Mock).toHaveBeenCalledWith({ storage: {} }) + expect(encryptionV3Mock).toHaveBeenCalledWith({ schemas: [FAKE_TABLE] }) + expect(requireUsableEncryptConfigMock).toHaveBeenCalled() + expect(ctx.client).toBe(encryptionClientStub) + expect(ctx.tables.get('transaction')).toBe(FAKE_TABLE) + }) + + it('errors with `contract emit` guidance when no contract.json exists', async () => { + detectPrismaNextMock.mockReturnValue(true) + const exit = spyExit() + const consoleError = spyConsoleError() + + await expect(loadEncryptionContext()).rejects.toThrow('process.exit:1') + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('prisma-next contract emit'), + ) + + exit.mockRestore() + consoleError.mockRestore() + }) + + it('errors when the contract has no cipherstash columns', async () => { + detectPrismaNextMock.mockReturnValue(true) + fsMocks.existsSync.mockImplementation((p: string) => + p.endsWith('prisma/contract.json'), + ) + deriveStackSchemasV3Mock.mockReturnValue([]) + const exit = spyExit() + const consoleError = spyConsoleError() + + await expect(loadEncryptionContext()).rejects.toThrow('process.exit:1') + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('No cipherstash-encrypted columns'), + ) + + exit.mockRestore() + consoleError.mockRestore() + }) +}) + +describe('backfillCommand — cs_migrations bootstrap', () => { + it('installs the migrations schema before running the backfill', async () => { + detectPrismaNextMock.mockReturnValue(true) + fsMocks.existsSync.mockImplementation((p: string) => + p.endsWith('src/prisma/contract.json'), + ) + deriveStackSchemasV3Mock.mockReturnValue([FAKE_TABLE]) + + const { backfillCommand } = await import('../backfill.js') + await backfillCommand({ + table: 'transaction', + column: 'email', + pkColumn: 'id', + confirmDualWritesDeployed: true, + }) + + expect(migrateMocks.installMigrationsSchema).toHaveBeenCalledTimes(1) + expect(migrateMocks.runBackfill).toHaveBeenCalledTimes(1) + const installOrder = + migrateMocks.installMigrationsSchema.mock.invocationCallOrder[0]! + const backfillOrder = migrateMocks.runBackfill.mock.invocationCallOrder[0]! + expect(installOrder).toBeLessThan(backfillOrder) + }) +}) diff --git a/packages/cli/src/commands/encrypt/__tests__/v2-lifecycle-composition.integration.test.ts b/packages/cli/src/commands/encrypt/__tests__/v2-lifecycle-composition.integration.test.ts new file mode 100644 index 000000000..15ea2b01d --- /dev/null +++ b/packages/cli/src/commands/encrypt/__tests__/v2-lifecycle-composition.integration.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * The seam between lifecycle RESOLUTION and the commands that act on it. + * + * `encrypt-v3.test.ts` stubs `resolveColumnLifecycle` and hand-writes the value + * it returns; `resolve-eql.test.ts` separately proves a pure-v2 table produces + * that value. Both can stay green while the shape passed between them changes, + * because neither runs the real producer into the real consumer — and the + * commands' v2/v3 branch is chosen entirely from that shape. + * + * So here `resolve-eql.js` is NOT mocked at all: `resolveColumnLifecycle`, + * `explainUnresolved`, `pickEncryptedColumn`, `listEncryptedColumns` and + * `columnExists` all run for real, and the pure-v2 verdict is derived from a + * real manifest hint plus a real catalog read rather than asserted into + * existence. Only genuine boundaries are faked: `pg`, the filesystem, prompts, + * and the effectful migrate helpers that write to the database (#787 review + * follow-up). + */ + +const queryMock = vi.hoisted(() => + vi.fn(async (_sql: string, _params?: unknown[]) => ({ + rows: [] as unknown[], + })), +) +vi.mock('pg', () => ({ + default: { + Client: class { + connect = vi.fn(async () => {}) + end = vi.fn(async () => {}) + query = queryMock + }, + }, +})) + +/** + * Spread the real module and override only the functions that TOUCH something — + * the manifest file and the database. Everything resolution actually reasons + * with (`listEncryptedColumns`, `columnExists`, `pickEncryptedColumn`, + * `classifyEqlDomain`) stays real and runs against `queryMock` below. + */ +const migrateMocks = vi.hoisted(() => ({ + readManifest: vi.fn(async () => null as unknown), + countUnencrypted: vi.fn(async () => 0), + progress: vi.fn( + async () => ({ phase: 'cut-over' }) as { phase: string } | null, + ), + appendEvent: vi.fn(async () => {}), + setManifestTargetPhase: vi.fn(async () => {}), + renameEncryptedColumns: vi.fn(async () => {}), + migrateConfig: vi.fn(async () => {}), + activateConfig: vi.fn(async () => {}), + reloadConfig: vi.fn(async () => {}), +})) +vi.mock('@cipherstash/migrate', async (importOriginal) => ({ + ...(await importOriginal()), + ...migrateMocks, +})) + +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + outro: vi.fn(), + confirm: vi.fn(async () => true), + isCancel: vi.fn(() => false), + log: { + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + step: vi.fn(), + }, + note: vi.fn(), +})) +vi.mock('@/config/index.js', () => ({ + loadStashConfig: vi.fn(async () => ({ databaseUrl: 'postgres://test' })), +})) +vi.mock('@/commands/db/detect.js', () => ({ + detectDrizzle: vi.fn(() => false), +})) +vi.mock('@/commands/init/utils.js', () => ({ + detectPackageManager: vi.fn(() => 'npm'), + runnerCommand: vi.fn((_pm: string, ref: string) => `npx ${ref}`), +})) +vi.mock('../drizzle-helper.js', () => ({ + scaffoldDrizzleMigration: vi.fn(async ({ name }: { name: string }) => ({ + path: `drizzle/${name}.sql`, + })), +})) +const writeFileMock = vi.hoisted(() => vi.fn()) +vi.mock('node:fs', () => ({ + default: { mkdirSync: vi.fn(), writeFileSync: writeFileMock }, +})) + +import * as p from '@clack/prompts' +import { cutoverCommand } from '../cutover.js' +import { dropCommand } from '../drop.js' + +function spyExit() { + return vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never) +} + +/** Columns the faked catalog reports as EQL-domain typed. Empty = pure v2. */ +let eqlColumns: { column: string; domain_name: string }[] = [] +/** Columns the faked catalog reports as existing at all. */ +let existingColumns: string[] = [] + +/** + * Route on the catalog statements these paths issue. Each is matched on text + * unique to it, and ORDER MATTERS: three of them end in `AS exists`, and + * `columnExists` and `listEncryptedColumns` both embed the same shared + * `to_regclass` expression. Matching loosely silently answers the wrong probe — + * routing the v2 pending-config check into `columnExists` made cutover report + * "no pending EQL configuration" instead of running the ladder. + */ +function fakeCatalog() { + queryMock.mockImplementation(async (sql: string, params?: unknown[]) => { + if (typeof sql !== 'string') return { rows: [] } + // The v2 config machine exists… + if (sql.includes("to_regclass('public.eql_v2_configuration')")) { + return { rows: [{ exists: 'eql_v2_configuration' }] } + } + // …and holds a pending row to promote. + if (sql.includes("state = 'pending'")) return { rows: [{ exists: true }] } + if (sql.includes('typname AS domain_name')) return { rows: eqlColumns } + if (sql.includes('pg_attribute') && sql.includes('AS exists')) { + return { + rows: [{ exists: existingColumns.includes(String(params?.[2])) }], + } + } + return { rows: [] } + }) +} + +beforeEach(() => { + vi.clearAllMocks() + // The hint `encrypt backfill` records for EVERY column, v2 included. It is + // what used to be discarded into a guess, and what must now be ignored + // harmlessly on a table with no v3 columns to mis-claim. + migrateMocks.readManifest.mockResolvedValue({ + tables: { + users: [{ column: 'ssn', encryptedColumn: 'ssn_encrypted' }], + }, + }) + migrateMocks.progress.mockResolvedValue({ phase: 'cut-over' }) + eqlColumns = [] + existingColumns = ['ssn', 'ssn_encrypted'] + fakeCatalog() +}) + +describe('the pure-v2 lifecycle, resolved for real end to end', () => { + it('generates the v2 plaintext drop for a table whose encrypted column is legacy v2', async () => { + const exitSpy = spyExit() + + await dropCommand({ table: 'users', column: 'ssn' }) + + expect(exitSpy).not.toHaveBeenCalled() + + // Reached the v2 ladder: `_plaintext`, not the v3 target ``. + const sql = writeFileMock.mock.calls[0]?.[1] as string + expect(sql).toContain('DROP COLUMN "ssn_plaintext"') + // The v3-only coverage gate must not run on a v2 column. + expect(migrateMocks.countUnencrypted).not.toHaveBeenCalled() + }) + + it('runs the v2 rename and config promotion for a table with no EQL v3 columns', async () => { + migrateMocks.progress.mockResolvedValue({ phase: 'backfilled' }) + + const exitSpy = spyExit() + + await cutoverCommand({ table: 'users', column: 'ssn' }) + + expect(exitSpy).not.toHaveBeenCalled() + expect(migrateMocks.renameEncryptedColumns).toHaveBeenCalled() + expect(migrateMocks.migrateConfig).toHaveBeenCalled() + expect(migrateMocks.activateConfig).toHaveBeenCalled() + expect(p.log.error).not.toHaveBeenCalled() + }) + + it('refuses both commands when the table also holds an unidentifiable EQL v3 column', async () => { + // The mixed table from #772 finding 7. Same manifest hint, same v2 pair — + // the ONLY difference is an unrelated v3 column, which is what makes the + // recorded pairing meaningful and a fall-through a guess. Crossing this + // boundary is what neither existing half of the coverage can see. + eqlColumns = [{ column: 'email_enc', domain_name: 'eql_v3_text_search' }] + const exitSpy = spyExit() + + await dropCommand({ table: 'users', column: 'ssn' }) + await cutoverCommand({ table: 'users', column: 'ssn' }) + + // Refusing is the point: both must exit non-zero rather than act on + // `email_enc`, the unrelated v3 column the sole-EQL-column rule would + // otherwise claim. + expect(exitSpy).toHaveBeenCalledWith(1) + expect(writeFileMock).not.toHaveBeenCalled() + expect(migrateMocks.renameEncryptedColumns).not.toHaveBeenCalled() + const errors = vi.mocked(p.log.error).mock.calls.flat().join('\n') + expect(errors).toContain('is not an EQL v3 column') + expect(errors).toContain('ssn_encrypted') + }) +}) diff --git a/packages/cli/src/commands/encrypt/backfill.ts b/packages/cli/src/commands/encrypt/backfill.ts index 7f7edb006..1eb1532b5 100644 --- a/packages/cli/src/commands/encrypt/backfill.ts +++ b/packages/cli/src/commands/encrypt/backfill.ts @@ -2,6 +2,7 @@ import { appendEvent, detectColumnEqlVersion, type EqlVersion, + installMigrationsSchema, type ManifestColumn, progress, runBackfill, @@ -131,6 +132,13 @@ export async function backfillCommand(options: BackfillCommandOptions) { process.on('SIGINT', onSignal) process.on('SIGTERM', onSignal) db = await pool.connect() + + // `stash eql install` normally creates `cipherstash.cs_migrations`, but + // not every integration runs it — Prisma Next installs EQL through its + // own migration graph, which doesn't carry the tracking schema. The DDL + // is CREATE IF NOT EXISTS throughout, so this is a no-op everywhere else. + await installMigrationsSchema(db) + const pkColumn = options.pkColumn ?? (await detectPkColumn(db, options.table)) @@ -141,8 +149,11 @@ export async function backfillCommand(options: BackfillCommandOptions) { // v2 or v3 changes the rest of the LIFECYCLE (v3 has no cut-over — the // ladder is backfill → switch-by-name → drop), so detect it up front, // record it in the manifest, and tell the user which path they're on. - // `null` means the target column doesn't exist or isn't an EQL domain — - // let the existing checks below produce their specific errors. + // `null` means the target column doesn't exist, isn't an EQL domain, or is + // a legacy `eql_v2_encrypted` column (no longer classified — v3 is the sole + // authored generation). Every one of those falls through to the v2 ladder, + // which is the correct default for the v2 case and lets the existing checks + // below produce their specific errors for the other two. const eqlVersion = await detectColumnEqlVersion( db, options.table, @@ -553,12 +564,19 @@ function buildManifestEntry( // convention only, never relied upon. encryptedColumn, // v2's ladder ends with the rename cut-over; v3 has none — its end - // state is the plaintext column dropped. + // state is the plaintext column dropped. An unclassified column (null, + // which now includes a legacy v2 domain) takes the v2 ladder. targetPhase: eqlVersion === 3 ? 'dropped' : 'cut-over', } if (pkColumn) entry.pkColumn = pkColumn - // Absent means UNKNOWN (detection couldn't see the column), not v2 — - // readers fall back to the domain type in the database. + // Absent means the classifier returned null: the column isn't there, isn't + // an EQL domain, or carries the legacy `eql_v2_encrypted` domain (which + // `classifyEqlDomain` no longer recognises — v3 is the sole authored + // generation). Readers fall back to the live domain type, which yields null + // for those same three cases, so `encrypt status` reports no version rather + // than guessing one. Manifests written before v2 classification was dropped + // still carry `eqlVersion: 2`, so existing v2 columns keep their version; + // only a v2 column backfilled from here on records none. if (eqlVersion) entry.eqlVersion = eqlVersion return entry } diff --git a/packages/cli/src/commands/encrypt/context.ts b/packages/cli/src/commands/encrypt/context.ts index 1975e2d96..e1facbed2 100644 --- a/packages/cli/src/commands/encrypt/context.ts +++ b/packages/cli/src/commands/encrypt/context.ts @@ -1,7 +1,12 @@ import fs from 'node:fs' import path from 'node:path' import type { EncryptionClient } from '@cipherstash/stack/encryption' -import { loadStashConfig, type ResolvedStashConfig } from '@/config/index.js' +import { detectPrismaNext } from '@/commands/db/detect.js' +import { + loadStashConfig, + type ResolvedStashConfig, + requireUsableEncryptConfig, +} from '@/config/index.js' /** * Structural shape of `@cipherstash/stack`'s `EncryptedTable` class. @@ -45,6 +50,13 @@ export async function loadEncryptionContext(): Promise { const resolvedPath = path.resolve(process.cwd(), stashConfig.client) if (!fs.existsSync(resolvedPath)) { + // Prisma Next projects have no hand-authored encryption client — the + // schema lives in the emitted contract.json and the runtime derives it + // via `cipherstashFromStack`. Mirror that derivation here so the encrypt + // commands work without asking users to author a bridge file. + const derived = await tryLoadPrismaNextContext(stashConfig) + if (derived) return derived + console.error( `Error: Encrypt client file not found at ${resolvedPath}\n\nCheck the "client" path in your stash.config.ts.`, ) @@ -138,6 +150,129 @@ export async function loadEncryptionContext(): Promise { process.exit(1) } + // The same refusal `stash db push` / `db validate` get from + // `loadEncryptConfig`, applied here because `stash encrypt` does not go + // through that loader. Called, not re-implemented: it guards one file, so the + // two commands must say one thing about it. Without this, `requireTable` + // reported `Table "users" was not found … Available: __stash_placeholder__`, + // naming the symptom and not the cause (#787 review). + requireUsableEncryptConfig(client.getEncryptConfig(), stashConfig.client) + + return { stashConfig, client, tables } +} + +/** + * Well-known locations for a Prisma Next emitted contract, relative to the + * project root. `prisma-next contract emit` writes `contract.json` next to + * the authored contract, which the scaffolds place under `src/prisma/` or + * `prisma/`. + */ +const PRISMA_NEXT_CONTRACT_CANDIDATES = [ + 'src/prisma/contract.json', + 'prisma/contract.json', + 'contract.json', +] + +/** + * Derive an `EncryptionContext` for a Prisma Next project — the fallback + * used when the configured encrypt client file does not exist. + * + * Prisma Next integrations deliberately have no client file: encrypted + * columns are declared in the PSL contract, and the runtime adapter derives + * the v3 schemas from the emitted `contract.json` (`deriveStackSchemasV3`) + * before constructing the same `EncryptionV3` client this function builds. + * Reusing that derivation keeps the CLI's view of the schema identical to + * the application's. + * + * Returns `undefined` when the project doesn't look like Prisma Next, so + * the caller can fall through to its existing missing-client error. Inside + * a detected Prisma Next project, failures are hard errors (exit 1) with + * the specific missing piece named — falling through to "client file not + * found" from here would point users at a file they are not supposed to + * author. + * + * Both `@cipherstash/prisma-next` and `@cipherstash/stack` are resolved + * from the *user's* project (via jiti anchored at their package.json), not + * from the CLI's own dependency tree, so the derived schemas and client + * always match the versions the application runs. + */ +async function tryLoadPrismaNextContext( + stashConfig: ResolvedStashConfig, +): Promise { + const cwd = process.cwd() + if (!detectPrismaNext(cwd)) return undefined + + const contractPath = PRISMA_NEXT_CONTRACT_CANDIDATES.map((candidate) => + path.resolve(cwd, candidate), + ).find((candidate) => fs.existsSync(candidate)) + if (!contractPath) { + console.error( + `Error: This looks like a Prisma Next project, but no emitted contract was found.\n` + + `Searched: ${PRISMA_NEXT_CONTRACT_CANDIDATES.join(', ')}\n\n` + + 'Run `prisma-next contract emit` first. (Or point "client" in stash.config.ts at an encryption client file to skip contract derivation.)', + ) + process.exit(1) + } + + const { createJiti } = await import('jiti') + const jiti = createJiti(path.join(cwd, 'package.json'), { + interopDefault: true, + }) + + let deriveStackSchemasV3: (contract: unknown) => readonly unknown[] + let EncryptionV3: (opts: { + schemas: readonly unknown[] + }) => Promise + try { + const prismaNextV3 = (await jiti.import('@cipherstash/prisma-next/v3')) as { + deriveStackSchemasV3: typeof deriveStackSchemasV3 + } + deriveStackSchemasV3 = prismaNextV3.deriveStackSchemasV3 + const stackV3 = (await jiti.import('@cipherstash/stack/v3')) as { + EncryptionV3: typeof EncryptionV3 + } + EncryptionV3 = stackV3.EncryptionV3 + } catch (error) { + console.error( + 'Error: Failed to load @cipherstash/prisma-next / @cipherstash/stack from this project.\n' + + 'Both must be installed to run encrypt commands against a Prisma Next contract.\n', + ) + console.error(error) + process.exit(1) + } + + let schemas: readonly unknown[] + try { + const contractJson = JSON.parse( + fs.readFileSync(contractPath, 'utf-8'), + ) as unknown + schemas = deriveStackSchemasV3(contractJson) + } catch (error) { + // deriveStackSchemasV3's own errors are author-facing and name the + // offending column; JSON.parse errors name the broken file below. + console.error( + `Error: Failed to derive encryption schemas from ${contractPath}\n`, + ) + console.error(error) + process.exit(1) + } + + if (schemas.length === 0) { + console.error( + `Error: No cipherstash-encrypted columns found in ${contractPath}.\n\n` + + 'Declare at least one `cipherstash.*()` column in your contract and re-run `prisma-next contract emit`.', + ) + process.exit(1) + } + + const client = await EncryptionV3({ schemas }) + requireUsableEncryptConfig(client.getEncryptConfig(), contractPath) + + const tables = new Map() + for (const schema of schemas as EncryptedTableLike[]) { + tables.set(schema.tableName, schema) + } + return { stashConfig, client, tables } } diff --git a/packages/cli/src/commands/encrypt/cutover.ts b/packages/cli/src/commands/encrypt/cutover.ts index 6584ef075..1080e9524 100644 --- a/packages/cli/src/commands/encrypt/cutover.ts +++ b/packages/cli/src/commands/encrypt/cutover.ts @@ -70,7 +70,7 @@ export async function cutoverCommand(options: CutoverCommandOptions) { // DOMAIN TYPES (manifest name as a hint; the `_encrypted` naming is // a convention, never relied upon) before any phase/config checks so v3 // users get the real answer, not a confusing precondition error. - const { info, candidates } = await resolveColumnLifecycle( + const { info, candidates, unresolvedHint } = await resolveColumnLifecycle( client, options.table, options.column, @@ -78,12 +78,15 @@ export async function cutoverCommand(options: CutoverCommandOptions) { // Fail closed on ambiguity: `info === null` with EQL columns present // means we can't tell WHICH lifecycle applies — running the v2 config // machine against (possibly) v3 columns would only produce a misleading - // downstream error. (No EQL columns at all, or the post-cutover v2 - // same-name state, still falls through to the v2 preconditions below.) + // downstream error. (A table with no EQL v3 columns still falls through to + // the v2 preconditions below — which now also covers the post-cutover v2 + // same-name state, since an `eql_v2_encrypted` column is no longer + // classified and so never appears as a candidate.) const unresolved = explainUnresolved( options.table, options.column, candidates, + unresolvedHint, ) if (!info && unresolved) { p.log.error(unresolved) @@ -92,8 +95,32 @@ export async function cutoverCommand(options: CutoverCommandOptions) { } const state = await progress(client, options.table, options.column) + // `via: 'sole'` means only that this is the table's one remaining EQL + // candidate once the plaintext column itself is excluded — nothing ties it + // to the plaintext column the user named. On a mixed table (a v2 pair the + // classifier no longer sees, plus one unrelated v3 column) that guess is + // simply wrong, and reporting "nothing to do for EQL v3" for it told a + // scripted rollout the cut-over had succeeded when the v2 rename never ran + // (#772 review, finding 7). + // + // Deliberately at TOP LEVEL, not inside the `version === 3` branch, so it + // mirrors `drop.ts` exactly. Equivalent today — `classifyEqlDomain` + // recognises `eql_v3_*` only, so a non-null `info` is always version 3 — + // but the v2 ladder below performs an irreversible rename plus config + // promotion. Were v2 classification restored, or a v4 family added, a + // nested guard would let `cutover` rename on a guess that `drop` refuses + // (#787 review). + if (info?.via === 'sole') { + p.log.error( + `${options.table}.${info.column} (${info.domain}) is the only EQL column left on ${options.table} once "${options.column}" itself is excluded, but nothing confirms it encrypts "${options.column}" — refusing to report a cut-over outcome on that guess. If "${options.column}" pairs with a legacy eql_v2_encrypted column, resolution cannot see it (this command resolves EQL v3 counterparts only): complete that column's v2 lifecycle yourself with the eql_v2 SQL — \`SELECT eql_v2.rename_encrypted_columns();\` plus the config promotion — since no stash command can drive it here. Otherwise record the pairing: re-run \`stash encrypt backfill --table ${options.table} --column ${options.column} --encrypted-column \`.`, + ) + exitCode = 1 + return + } + if (info?.version === 3) { const encryptedColumn = info.column + if (state?.phase === 'dropped') { // Terminal phase — the lifecycle already finished. Not an error and // not "finish the backfill": there is nothing left to backfill. diff --git a/packages/cli/src/commands/encrypt/drop.ts b/packages/cli/src/commands/encrypt/drop.ts index 2fe440bb0..d016a0c63 100644 --- a/packages/cli/src/commands/encrypt/drop.ts +++ b/packages/cli/src/commands/encrypt/drop.ts @@ -74,7 +74,7 @@ export async function dropCommand(options: DropCommandOptions) { // column, droppable straight after `backfilled`. The version and the // encrypted column's name are resolved from the DOMAIN TYPES (manifest // name as a hint) — the `_encrypted` naming is a convention only. - const { info, candidates } = await resolveColumnLifecycle( + const { info, candidates, unresolvedHint } = await resolveColumnLifecycle( client, options.table, options.column, @@ -84,12 +84,14 @@ export async function dropCommand(options: DropCommandOptions) { // the wrong ciphertext and generate an irreversible drop of the wrong // data. (The post-cutover v2 state — `` itself carries the v2 // domain, counterpart legitimately unresolvable — falls through to the - // v2 path; live truth from the DB wins over the manifest's cached - // version throughout.) + // v2 path: the classifier recognises `eql_v3_*` only, so that column is + // not a candidate and the table reads as having no EQL columns. Live + // truth from the DB wins over the manifest's cached version throughout.) const unresolved = explainUnresolved( options.table, options.column, candidates, + unresolvedHint, ) if (!info && unresolved) { p.log.error(unresolved) @@ -102,9 +104,16 @@ export async function dropCommand(options: DropCommandOptions) { // that destroys the only copy of this data. Dropping is the single // irreversible step in the lifecycle, so it demands a positively // asserted pairing (manifest hint or the naming convention). + // + // The remedy must NOT name `info.column`. That is the guess itself, and + // recording it turns the next resolution into `via: 'hint'`, which walks + // straight past this gate — while the coverage check below passes + // vacuously, because a legitimately-backfilled unrelated column is + // non-NULL on every row. Following the old message verbatim generated a + // live `DROP COLUMN` on the plaintext at exit 0 (#772 review, finding 7). if (info?.via === 'sole') { p.log.error( - `${options.table}.${info.column} (${info.domain}) is the table's only encrypted column, but nothing confirms it encrypts "${options.column}" — refusing to generate an irreversible drop on that guess. Record the pairing and retry: re-run \`stash encrypt backfill --table ${options.table} --column ${options.column} --encrypted-column ${info.column}\` (which writes it to the manifest), or set "encryptedColumn": "${info.column}" for this column in .cipherstash/migrations.json.`, + `${options.table}.${info.column} (${info.domain}) is the only EQL column left on ${options.table} once "${options.column}" itself is excluded, but nothing confirms it encrypts "${options.column}" — refusing to generate an irreversible drop on that guess. Identify the column that actually encrypts "${options.column}" and record that pairing: re-run \`stash encrypt backfill --table ${options.table} --column ${options.column} --encrypted-column \` (which writes it to the manifest), or set "encryptedColumn" for this column in .cipherstash/migrations.json. If "${options.column}" pairs with a legacy eql_v2_encrypted column, resolution cannot see it (this command resolves EQL v3 counterparts only): complete that column's v2 lifecycle yourself with the eql_v2 SQL, since no stash command can drive it here — and do not record ${info.column}.`, ) exitCode = 1 return diff --git a/packages/cli/src/commands/encrypt/lib/__tests__/resolve-eql.test.ts b/packages/cli/src/commands/encrypt/lib/__tests__/resolve-eql.test.ts new file mode 100644 index 000000000..154c5e563 --- /dev/null +++ b/packages/cli/src/commands/encrypt/lib/__tests__/resolve-eql.test.ts @@ -0,0 +1,276 @@ +/** + * Pins {@link explainUnresolved}'s fail-closed contract now that the domain + * classifier recognises `eql_v3_*` only. + * + * `listEncryptedColumns` can no longer emit `version: 2` — a legacy + * `eql_v2_encrypted` column is not classified as an EQL column at all, so it + * never reaches this function as a candidate. Every pure-v2 table therefore + * arrives here as an EMPTY candidate list, both pre-cutover (`` / + * `_encrypted`) and post-cutover (the ciphertext renamed onto the + * plaintext column's own name), and the first guard falls through on it. + * + * These tests exist so removing the now-unreachable `version === 2` branch is + * provably behaviour-preserving, and so a future v2 sweep cannot delete the + * empty-list guard the v2 lifecycle actually depends on. That guard has to hold + * even when the manifest recorded an `encryptedColumn` — `backfill` records one + * for v2 columns too — which is the `candidates.length > 0` gate on the + * fail-closed path (#787 review). + */ + +import type { EncryptedColumnInfo } from '@cipherstash/migrate' +import type pg from 'pg' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Only the two I/O boundaries are replaced. `pickEncryptedColumn` stays real — +// it IS the resolution rule under test, and stubbing it (as `encrypt-v3.test.ts` +// stubs `resolveColumnLifecycle`) is why the hint-discard below had no coverage. +const readManifest = vi.hoisted(() => + vi.fn(async () => null as { tables: Record } | null), +) +const listEncryptedColumns = vi.hoisted(() => + vi.fn(async () => [] as EncryptedColumnInfo[]), +) +vi.mock('@cipherstash/migrate', async (importOriginal) => ({ + ...(await importOriginal()), + readManifest, + listEncryptedColumns, +})) + +const { explainUnresolved, resolveColumnLifecycle } = await import( + '../resolve-eql.js' +) + +/** + * A `pg` double answering only the `pg_attribute` existence probe, from a set + * of column names the table is pretended to have. That probe is the whole + * point: it is what separates "the hint is stale" from "the hint names a real + * column that simply is not EQL v3". + */ +const clientWithColumns = (...columns: string[]): pg.ClientBase => + ({ + // `columnExists` binds [table, schema, column] — it splits schema-qualified + // names and quotes with `format('%I')` so the lookup is case-EXACT. This + // double matches case-exactly for the same reason: a case-insensitive + // fixture would keep passing if the probe regressed to a bare + // `to_regclass($1)`, which case-folds (#787 review). + query: async (_sql: string, params: unknown[]) => ({ + rows: [{ exists: columns.includes(String(params[2])) }], + }), + }) as unknown as pg.ClientBase + +const v3 = ( + column: string, + domain = 'eql_v3_text_eq', +): EncryptedColumnInfo => ({ + column, + domain, + version: 3, +}) + +describe('explainUnresolved', () => { + it('falls through (null) when the table has no EQL columns at all', () => { + // Both the not-yet-backfilled case and the post-cutover v2 same-name case + // land here: the caller's own preconditions produce the accurate error. + expect(explainUnresolved('users', 'email', [])).toBeNull() + }) + + it('still falls through when no EQL v3 columns exist BUT a hint was recorded', () => { + // The pure-v2 table. `encrypt backfill` records `encryptedColumn` for v2 + // columns too, so a hint is present on every table backfilled with this + // release — it must not flip the empty-candidate fall-through into a + // refusal, because `cutover` / `drop` in this same build still implement + // the v2 ladder this falls through to (#787 review). + expect(explainUnresolved('users', 'ssn', [], 'ssn_encrypted')).toBeNull() + }) + + it('fails closed, naming every candidate, when none is identifiable', () => { + const message = explainUnresolved('users', 'email', [ + v3('a_enc'), + v3('b_enc', 'eql_v3_text_search'), + ]) + + expect(message).toContain('Cannot identify which encrypted column') + expect(message).toContain('a_enc (eql_v3_text_eq)') + expect(message).toContain('b_enc (eql_v3_text_search)') + expect(message).toContain('--encrypted-column') + }) + + it('gives no free pass to a candidate sharing the plaintext column name', () => { + // The removed branch exempted a SAME-NAME candidate, but only at + // `version === 2`. A v3 domain on the plaintext column's own name is not + // the post-cutover state (v3 has no cut-over rename), so it must still + // fail closed rather than let a destructive command guess a lifecycle. + const message = explainUnresolved('users', 'email', [ + v3('email'), + v3('email_enc'), + ]) + + expect(message).toContain('Cannot identify which encrypted column') + }) +}) + +/** + * #772 review, finding 7 — the mixed-table dead end. + * + * `classifyEqlDomain` recognises `eql_v3_*` only, so on a table holding a v2 + * pair (`ssn` / `ssn_encrypted`) plus one unrelated v3 column, the v2 ciphertext + * column is not a candidate at all. `pickEncryptedColumn`'s sole-EQL-column rule + * then claims the unrelated v3 column for `ssn`. + * + * `encrypt backfill` records the true pairing in the manifest, so the answer is + * on disk — but the hint was thrown away whenever it failed to resolve, and the + * re-pick without it reached the sole rule. Recording the pairing changed + * nothing, and `drop`'s remedy told the user to record the guess instead. + */ +describe('resolveColumnLifecycle — a recorded hint that is not a v3 candidate', () => { + const V3_OTHER: EncryptedColumnInfo = { + column: 'email_enc', + domain: 'eql_v3_text_search', + version: 3, + } + + beforeEach(() => { + vi.clearAllMocks() + readManifest.mockResolvedValue(null) + listEncryptedColumns.mockResolvedValue([]) + }) + + it('does not fall back to the sole-column guess when the manifest names a counterpart', async () => { + // The v2 pair is invisible to the classifier; only email_enc is a candidate. + listEncryptedColumns.mockResolvedValue([V3_OTHER]) + readManifest.mockResolvedValue({ + tables: { users: [{ column: 'ssn', encryptedColumn: 'ssn_encrypted' }] }, + }) + + const { info, unresolvedHint } = await resolveColumnLifecycle( + clientWithColumns('ssn', 'ssn_encrypted', 'email_enc'), + 'users', + 'ssn', + ) + + expect(info).toBeNull() + expect(unresolvedHint).toBe('ssn_encrypted') + }) + + // The pure-v2 shape, and the regression the `candidates.length > 0` gate + // exists to prevent (#787 review). `backfill` records `encryptedColumn` + // unconditionally — v2 included — so EVERY pure-v2 table backfilled with this + // release carries a hint naming a real, existing, non-v3 column. Without the + // gate, `columnExists` returned true, `unresolvedHint` was set, and + // `cutover` / `drop` refused a lifecycle this same build still performs. + it('does not fail closed on a pure-v2 table, where no v3 column can be mis-claimed', async () => { + // No v3 columns at all: just the `ssn` / `ssn_encrypted` v2 pair, which the + // classifier does not see. + listEncryptedColumns.mockResolvedValue([]) + readManifest.mockResolvedValue({ + tables: { users: [{ column: 'ssn', encryptedColumn: 'ssn_encrypted' }] }, + }) + + const { info, candidates, unresolvedHint } = await resolveColumnLifecycle( + clientWithColumns('ssn', 'ssn_encrypted'), + 'users', + 'ssn', + ) + + expect(info).toBeNull() + expect(candidates).toEqual([]) + // The fall-through signal: no hint reported, so `explainUnresolved` returns + // null and the caller reaches its own v2 preconditions. + expect(unresolvedHint).toBeUndefined() + expect( + explainUnresolved('users', 'ssn', candidates, unresolvedHint), + ).toBeNull() + }) + + // #787 review. `columnExists` used to be a local copy using a bare + // `to_regclass($1)`, which PARSES and case-folds its argument — so on a + // Prisma-style `"User"` table the probe reported "missing", the hint was + // treated as stale, and the fail-closed above silently did not fire. It fell + // through to the sole/convention rules and resolved a guess, which is exactly + // what #772 finding 7 exists to prevent. Now delegated to + // `@cipherstash/migrate`'s shared, case-exact probe. + it('fires the fail-closed on a mixed-case table name too', async () => { + listEncryptedColumns.mockResolvedValue([V3_OTHER]) + readManifest.mockResolvedValue({ + tables: { User: [{ column: 'ssn', encryptedColumn: 'ssn_encrypted' }] }, + }) + + const { info, unresolvedHint } = await resolveColumnLifecycle( + clientWithColumns('ssn', 'ssn_encrypted', 'email_enc'), + 'User', + 'ssn', + ) + + expect(info).toBeNull() + expect(unresolvedHint).toBe('ssn_encrypted') + }) + + it('explains the recorded counterpart by name rather than listing candidates', async () => { + const message = explainUnresolved( + 'users', + 'ssn', + [V3_OTHER], + 'ssn_encrypted', + ) + + expect(message).toContain('ssn_encrypted') + expect(message).toContain('not an EQL v3 column') + // Naming the guess here is what sent users to `--encrypted-column email_enc`. + expect(message).not.toContain('--encrypted-column email_enc') + }) + + // The fallback the comment actually describes — a hint naming a column that + // is simply gone — must still resolve through convention. + it('still falls back to convention when the hint is genuinely stale', async () => { + listEncryptedColumns.mockResolvedValue([ + { column: 'ssn_encrypted', domain: 'eql_v3_text_eq', version: 3 }, + ]) + readManifest.mockResolvedValue({ + tables: { users: [{ column: 'ssn', encryptedColumn: 'ssn_old' }] }, + }) + + // `ssn_old` is gone from the table entirely — the genuinely stale case. + const { info } = await resolveColumnLifecycle( + clientWithColumns('ssn', 'ssn_encrypted'), + 'users', + 'ssn', + ) + + expect(info?.column).toBe('ssn_encrypted') + expect(info?.via).toBe('convention') + }) + + it('resolves through the hint when it does name a candidate', async () => { + listEncryptedColumns.mockResolvedValue([ + { column: 'ssn_enc', domain: 'eql_v3_text_eq', version: 3 }, + ]) + readManifest.mockResolvedValue({ + tables: { users: [{ column: 'ssn', encryptedColumn: 'ssn_enc' }] }, + }) + + const { info, unresolvedHint } = await resolveColumnLifecycle( + clientWithColumns('ssn', 'ssn_enc'), + 'users', + 'ssn', + ) + + expect(info?.column).toBe('ssn_enc') + expect(info?.via).toBe('hint') + expect(unresolvedHint).toBeUndefined() + }) + + // No manifest entry at all is the ordinary case; the sole rule still applies + // and `drop`'s own `via === 'sole'` gate is what refuses it. + it('leaves the sole-column rule alone when nothing was recorded', async () => { + listEncryptedColumns.mockResolvedValue([V3_OTHER]) + + const { info, unresolvedHint } = await resolveColumnLifecycle( + clientWithColumns('ssn', 'email_enc'), + 'users', + 'ssn', + ) + + expect(info?.via).toBe('sole') + expect(unresolvedHint).toBeUndefined() + }) +}) diff --git a/packages/cli/src/commands/encrypt/lib/resolve-eql.ts b/packages/cli/src/commands/encrypt/lib/resolve-eql.ts index 9ce9afb6a..d2b0ac889 100644 --- a/packages/cli/src/commands/encrypt/lib/resolve-eql.ts +++ b/packages/cli/src/commands/encrypt/lib/resolve-eql.ts @@ -1,4 +1,5 @@ import { + columnExists, type EncryptedColumnInfo, listEncryptedColumns, pickEncryptedColumn, @@ -23,6 +24,26 @@ export interface ResolvedLifecycle { * identifiable — callers name them instead of erroring blind. */ candidates: EncryptedColumnInfo[] + /** + * The manifest's recorded `encryptedColumn` when it named a real column that + * is NOT among `candidates`, AND `candidates` is non-empty — i.e. the pairing + * is on record, the column it names is not an EQL v3 column (typically legacy + * `eql_v2_encrypted`, which `classifyEqlDomain` no longer recognises), and + * there are v3 columns here that a guess could wrongly claim. + * + * Distinct from a stale hint. It means the answer IS known and disagrees + * with anything resolution could otherwise guess, so callers must fail closed + * naming it rather than fall through to `via: 'sole'` (#772 review, finding 7). + * + * Deliberately NOT set when `candidates` is empty. That is the pure-v2 table + * — a `` / `_encrypted` pair and nothing else — where there is no + * v3 column to mis-claim and so nothing to protect against. The v2 lifecycle + * is still implemented in `cutover.ts` / `drop.ts`, and those commands must + * keep reaching it; failing closed here would refuse a lifecycle this same + * build still performs, and tell the user to downgrade to reach it (#787 + * review). + */ + unresolvedHint?: string } /** @@ -59,35 +80,82 @@ export async function resolveColumnLifecycle( )?.encryptedColumn const candidates = await listEncryptedColumns(client, table) - let info = hint ? pickEncryptedColumn(candidates, column, hint) : null - // A stale hint (column since renamed/retyped) must not mask a resolvable - // counterpart — fall back to convention + sole-EQL-column resolution. - if (!info) info = pickEncryptedColumn(candidates, column) - return { info, candidates } + const hinted = hint ? pickEncryptedColumn(candidates, column, hint) : null + if (hinted) return { info: hinted, candidates } + + // The hint named a column that is not a candidate. Three very different + // reasons, and they must not share an outcome: + // + // - the column no longer exists (renamed, dropped) — a genuinely STALE hint, + // which must not mask a resolvable counterpart, so fall through; + // - the column exists, is not an EQL v3 column, and the table HAS v3 columns + // — the mixed table. The usual shape is a legacy `eql_v2_encrypted` + // counterpart, which `classifyEqlDomain` stopped recognising. Here the + // pairing IS known and falling through would discard it in favour of a + // guess: the sole-EQL-column rule claims an unrelated v3 column, `cutover` + // reports success for a rename it never performed, and `drop`'s remedy + // tells the user to record the guess (#772 review, finding 7). Fail closed; + // - the column exists, is not an EQL v3 column, and there are NO v3 columns + // on the table — the pure-v2 table. Nothing here can be mis-claimed, and + // `cutover` / `drop` still implement the v2 ladder, so fall through to it + // exactly as before. Gating on `candidates.length` is what keeps the + // protection above scoped to the mixed table it was written for (#787 + // review). + if ( + hint && + candidates.length > 0 && + (await columnExists(client, table, hint)) + ) { + return { info: null, candidates, unresolvedHint: hint } + } + + return { info: pickEncryptedColumn(candidates, column), candidates } } /** * Explain a failed resolution (`info === null`) to the user, or return - * `null` when the failure is fine to fall through to the v2 lifecycle: + * `null` when the failure is fine to fall through to the v2 lifecycle. * - * - No EQL columns at all → the v2 phase/config preconditions produce the - * accurate error ("not backfilled", "no pending config", …). - * - The plaintext column ITSELF carries the v2 domain → the normal - * post-cutover v2 state (`` was renamed onto the ciphertext), where - * "no counterpart" is expected, not a problem. + * The one fall-through case is "no EQL v3 columns at all", which the v2 + * phase/config preconditions turn into an accurate error ("not backfilled", + * "no pending config", …). Since `classifyEqlDomain` recognises `eql_v3_*` + * only, that case covers every pure-v2 table — both the pre-cutover pair + * (`` / `_encrypted`) and the post-cutover state where `` was + * renamed onto the ciphertext. Neither column is ever a candidate, so a pure-v2 + * table reaches the v2 ladder here regardless of what the manifest recorded; + * a recorded `encryptedColumn` must NOT turn that into a refusal, because + * `cutover.ts` / `drop.ts` in this same build still implement that ladder + * (#787 review). * - * Anything else means EQL columns exist but none is identifiable — the - * caller must fail closed with this message rather than guess a lifecycle. + * A non-empty candidate list therefore means EQL v3 columns exist but none is + * identifiable — the caller must fail closed with this message rather than + * guess a lifecycle, including when one candidate happens to share the + * plaintext column's name (v3 has no cut-over rename, so that is not the + * post-cutover state). */ export function explainUnresolved( table: string, column: string, candidates: readonly EncryptedColumnInfo[], + unresolvedHint?: string, ): string | null { + // "No EQL v3 columns at all" always falls through, even with a recorded hint. + // That is the pure-v2 table, and the v2 ladder in `cutover.ts` / `drop.ts` + // still handles it — the caller's own preconditions produce the accurate + // error. Ordered ahead of the hint branch deliberately: `resolveColumnLifecycle` + // already declines to set `unresolvedHint` on an empty candidate list, and this + // keeps the two agreeing for direct callers of this function (#787 review). if (candidates.length === 0) return null - if (candidates.some((c) => c.column === column && c.version === 2)) { - return null + + // The recorded pairing points at a real column that is not an EQL v3 column, + // on a table that does have v3 columns — almost always a legacy + // `eql_v2_encrypted` counterpart alongside an unrelated v3 one. Say exactly + // that: listing the v3 candidates here would invite the user to record one of + // them, which is how the guess used to get laundered into a `via: 'hint'` match. + if (unresolvedHint !== undefined) { + return `${table}.${column} is recorded as pairing with "${unresolvedHint}", but ${unresolvedHint} is not an EQL v3 column — it is most likely a legacy eql_v2_encrypted column. ${table} also holds EQL v3 columns, and none of them is a confirmed counterpart for ${column}, so this command cannot tell which lifecycle applies and will not guess.\n\nIf that pairing is wrong, correct or remove "encryptedColumn" for ${column} in .cipherstash/migrations.json and re-run. If it is right, ${column} is on the EQL v2 lifecycle, which no stash command can drive on this table — complete it yourself against ${unresolvedHint} with the eql_v2 SQL.` } + const listed = candidates .map((c) => ` - ${c.column} (${c.domain})`) .join('\n') diff --git a/packages/cli/src/commands/eql/__tests__/migration.test.ts b/packages/cli/src/commands/eql/__tests__/migration.test.ts index ba3f8d7d1..c0193b665 100644 --- a/packages/cli/src/commands/eql/__tests__/migration.test.ts +++ b/packages/cli/src/commands/eql/__tests__/migration.test.ts @@ -18,7 +18,16 @@ import { buildEqlV3MigrationSql, eqlMigrationCommand } from '../migration.js' // reports through. const clack = vi.hoisted(() => ({ spinnerInstance: { start: vi.fn(), stop: vi.fn() }, - log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() }, + // `step` is on the real clack `log`; omitting it made the sweep's + // per-statement report throw and land in the command's catch block, so no + // test ever saw the report. Keep it here. + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + step: vi.fn(), + }, intro: vi.fn(), note: vi.fn(), outro: vi.fn(), @@ -217,6 +226,14 @@ describe('eqlMigrationCommand — Drizzle', () => { it('rewrites a sibling migration with a broken v3 ALTER COLUMN', async () => { const out = join(tmp, 'drizzle') mkdirSync(out, { recursive: true }) + // The sweep is fail-closed: it rewrites a column only when the corpus + // positively declares it (and it isn't already encrypted). A real drizzle + // corpus carries this declaration in an earlier migration — supply it so + // the fixture matches what the sweep actually requires. + writeFileSync( + join(out, '0000_declare.sql'), + 'CREATE TABLE "users" ("email" text);\n', + ) const sibling = join(out, '0001_encrypt-email.sql') writeFileSync( sibling, @@ -239,10 +256,17 @@ describe('eqlMigrationCommand — Drizzle', () => { it('does not rewrite the EQL install migration it just generated', async () => { const out = join(tmp, 'drizzle') mkdirSync(out, { recursive: true }) - const generated = join(out, '0000_install-eql.sql') + // Fail-closed requires the corpus to positively declare the column before + // the sweep will touch it — supply the declaration a real drizzle corpus + // would carry, same as the sibling-rewrite test above. + writeFileSync( + join(out, '0000_declare.sql'), + 'CREATE TABLE "users" ("email" text);\n', + ) + const generated = join(out, '0001_install-eql.sql') // A sibling carrying the SAME statement — the differential that proves the // sweep ran at all, rather than no-opping over the whole directory. - const sibling = join(out, '0001_encrypt-email.sql') + const sibling = join(out, '0002_encrypt-email.sql') const unsafeAlter = 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE "undefined"."eql_v3_text_search";\n' writeFileSync(sibling, unsafeAlter) @@ -298,6 +322,44 @@ describe('eqlMigrationCommand — Drizzle', () => { ) }) + // A column the corpus never declares is fail-closed to `source-unknown`: left + // on disk, and reported so the user goes and checks its type. This is the most + // common skip on a real (e.g. squashed) corpus, and it renders through the + // `p.log.step` path that a missing mock method used to make throw — so assert + // the guidance text actually reaches the user, not just that a warning fired. + it('reports source-unknown guidance for an undeclared column and warns at the close', async () => { + const out = join(tmp, 'drizzle') + mkdirSync(out, { recursive: true }) + // No CREATE TABLE / ADD COLUMN anywhere declares "users"."email". + const sibling = join(out, '0001_encrypt-email.sql') + const unsafeAlter = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n' + writeFileSync(sibling, unsafeAlter) + spawnMock.mockImplementation(() => { + writeFileSync(join(out, '0002_install-eql.sql'), '') + return { status: 0, stdout: '', stderr: '' } + }) + + await eqlMigrationCommand({ drizzle: true, out }) + + // Left exactly as written — a source-unknown statement is never rewritten. + expect(readFileSync(sibling, 'utf-8')).toBe(unsafeAlter) + // The per-statement report reached the user with the source-unknown + // remediation (the whole point of the reason), not a crash into the catch. + const stepped = clack.log.step.mock.calls.map((c) => String(c[0])) + expect(stepped.some((msg) => msg.includes(sibling))).toBe(true) + expect( + stepped.some((msg) => + msg.includes("Check the column's current type in the database"), + ), + ).toBe(true) + // And the closing note warns the sweep did not fully complete. + const warned = clack.log.warn.mock.calls.map((c) => String(c[0])) + expect(warned.some((msg) => msg.includes('did not fully complete'))).toBe( + true, + ) + }) + it('aborts (exit 1) when drizzle-kit exits non-zero', async () => { spawnMock.mockReturnValue({ status: 1, stdout: '', stderr: 'boom' }) await expect( diff --git a/packages/cli/src/commands/eql/migration.ts b/packages/cli/src/commands/eql/migration.ts index 8923f7478..c2adf39c5 100644 --- a/packages/cli/src/commands/eql/migration.ts +++ b/packages/cli/src/commands/eql/migration.ts @@ -10,7 +10,10 @@ import { printNextSteps, SAFE_MIGRATION_NAME, } from '@/commands/db/install.js' -import { rewriteEncryptedAlterColumns } from '@/commands/db/rewrite-migrations.js' +import { + describeSkipReason, + rewriteEncryptedAlterColumns, +} from '@/commands/db/rewrite-migrations.js' import { detectPackageManager, execArgv, @@ -245,10 +248,11 @@ async function generateDrizzleEqlMigration( if (skipped.length > 0) { sweepIncomplete = true p.log.warn( - `Found ${skipped.length} ALTER-to-encrypted statement(s) the sweep could not rewrite automatically. Review and fix them before running your migrations:`, + `Found ${skipped.length} ALTER-to-encrypted statement(s) the sweep left alone. Review and fix them before running your migrations:`, ) - for (const { file, statement } of skipped) { + for (const { file, statement, reason } of skipped) { p.log.step(` - ${file}: ${statement}`) + p.log.step(` ${describeSkipReason(reason)}`) } } } catch (error) { diff --git a/packages/cli/src/commands/init/__tests__/placeholder-client-fixture.test.ts b/packages/cli/src/commands/init/__tests__/placeholder-client-fixture.test.ts new file mode 100644 index 000000000..c3ddbf548 --- /dev/null +++ b/packages/cli/src/commands/init/__tests__/placeholder-client-fixture.test.ts @@ -0,0 +1,74 @@ +/** + * Binds `__fixtures__/scaffold/*.ts` to what `generatePlaceholderClient` + * actually emits. + * + * The fixtures are the only thing in the repo that COMPILES the file + * `stash init` writes into a user's project — `tsconfig.scaffold.json` and the + * CI step that runs it exist for that. But a typechecked fixture is worthless + * if the generator can drift away from it, and the existing codegen tests only + * `toContain`-match fragments while `build-schema.test.ts` stubs the generator + * out entirely. That combination is how `Encryption({ schemas: [] })` shipped: + * every test was green and no compiler ever saw the output (#772 review, + * finding 6). + * + * So: byte-for-byte, both directions. Change a template, regenerate the + * fixture; the compiler then has an opinion about it. + */ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { PLACEHOLDER_TABLE_NAME } from '@/config/index.js' +import { generatePlaceholderClient } from '../utils.js' + +const FIXTURE_DIR = path.resolve( + fileURLToPath(import.meta.url), + '../../../../../__fixtures__/scaffold', +) + +const fixture = (name: string) => + readFileSync(path.join(FIXTURE_DIR, name), 'utf-8') + +describe('the scaffolded client fixtures match the generator', () => { + // Named `.generated.ts` so biome.json's existing exclusion leaves them alone: + // they are template OUTPUT, and reformatting them would break the byte-for-byte + // comparison below (which is the only thing tying the compiler to the generator). + it.each([ + ['generic.generated.ts', 'postgresql'], + ['drizzle.generated.ts', 'drizzle'], + ] as const)('%s', (file, integration) => { + expect(fixture(file)).toBe(generatePlaceholderClient(integration)) + }) + + // `supabase` shares the generic template; pin that so a future split does not + // silently leave the supabase path ungated. + it('supabase reuses the generic template', () => { + expect(generatePlaceholderClient('supabase')).toBe( + fixture('generic.generated.ts'), + ) + }) +}) + +describe('the scaffold compiles because it declares a table', () => { + it.each([ + 'generic.generated.ts', + 'drizzle.generated.ts', + ])('%s passes a non-empty schema set', (file) => { + const body = fixture(file) + // The empty form is a hard TS2769 against both overloads — `Encryption` + // requires at least one table by design (S-6), so the scaffold cannot go + // back to `schemas: []` without breaking every project it is written into. + expect(body).not.toContain('Encryption({ schemas: [] })') + expect(body).toContain('schemas: [placeholderTable]') + }) + + it.each([ + 'generic.generated.ts', + 'drizzle.generated.ts', + ])('%s uses the sentinel name the config loader refuses', (file) => { + // `loadEncryptConfig` exits 1 when this is the only table left, so the + // two must agree — otherwise the user gets a confusing "table not found" + // from whichever command runs next instead of "you never replaced this". + expect(fixture(file)).toContain(`'${PLACEHOLDER_TABLE_NAME}'`) + }) +}) diff --git a/packages/cli/src/commands/init/__tests__/utils-codegen-drizzle.test.ts b/packages/cli/src/commands/init/__tests__/utils-codegen-drizzle.test.ts new file mode 100644 index 000000000..d30246f14 --- /dev/null +++ b/packages/cli/src/commands/init/__tests__/utils-codegen-drizzle.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import type { SchemaDef } from '../types.js' +import { + generateClientFromSchemas, + generatePlaceholderClient, +} from '../utils.js' + +// `stash init --drizzle` writes these strings into the USER's repo as real +// source. Nothing type-checks a template literal, so a rename in +// `@cipherstash/stack-drizzle` cannot break the build here — it breaks the +// scaffolded project instead, in someone else's checkout. +// +// `utils-codegen.test.ts` covers the generated client's happy path. This file +// covers the surface that had no test at all — `generatePlaceholderClient`, +// whose doc block is a copy-paste reference the handoff agent works from — and +// asserts, for BOTH strings, that nothing removed with EQL v2 survives: +// - the `@cipherstash/stack-drizzle/v3` specifier (`./v3` collapsed into `.`) +// - `extractEncryptionSchemaV3` / `createEncryptionOperatorsV3` +// - `encryptedType`, the v2 config-flag column factory + +const SCHEMAS: SchemaDef[] = [ + { + tableName: 'users', + columns: [ + { name: 'email', domain: 'TextSearch' }, + { name: 'age', domain: 'IntegerOrd' }, + ], + }, +] + +/** Every drizzle-flavoured string `stash init` can write into a user's repo. */ +const GENERATED_SOURCES: Array<[string, string]> = [ + ['generateClientFromSchemas', generateClientFromSchemas('drizzle', SCHEMAS)], + ['generatePlaceholderClient', generatePlaceholderClient('drizzle')], +] + +describe.each( + GENERATED_SOURCES, +)('drizzle scaffold — %s', (_name, generated) => { + it('names the drizzle package root, never the removed ./v3 subpath', () => { + expect(generated).toContain('@cipherstash/stack-drizzle') + expect(generated).not.toContain('@cipherstash/stack-drizzle/v3') + }) + + it('uses the de-suffixed export names, never the removed *V3 aliases', () => { + expect(generated).not.toContain('extractEncryptionSchemaV3') + expect(generated).not.toContain('createEncryptionOperatorsV3') + }) + + it('never scaffolds the removed EQL v2 authoring surface', () => { + expect(generated).not.toContain('encryptedType') + expect(generated).not.toContain('eql_v2_encrypted') + }) +}) + +describe('generatePlaceholderClient (drizzle)', () => { + const placeholder = generatePlaceholderClient('drizzle') + + it('shows the harvest pattern with the collapsed root import', () => { + expect(placeholder).toContain( + "import { extractEncryptionSchema } from '@cipherstash/stack-drizzle'", + ) + expect(placeholder).toContain('extractEncryptionSchema(users)') + }) +}) diff --git a/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts b/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts index 1221065d5..59067ee14 100644 --- a/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts +++ b/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts @@ -20,15 +20,27 @@ describe('generateClientFromSchemas', () => { expect(out).toContain("age: types.IntegerOrd('age'),") expect(out).toContain("verified: types.Boolean('verified'),") expect(out).toContain("from '@cipherstash/stack/v3'") - expect(out).toContain('EncryptionV3(') + // `Encryption` is the current name; `EncryptionV3` is a deprecated alias. + // Same reasoning as the drizzle negatives below — this is a template + // literal written into the user's repo as real source, so nothing but an + // assertion here catches a scaffold that teaches the deprecated name. + expect(out).toContain('Encryption(') + expect(out).not.toContain('EncryptionV3') }) it('emits the chosen v3 domain factory per column (drizzle)', () => { const out = generateClientFromSchemas('drizzle', schemas) expect(out).toContain("email: types.TextSearch('email'),") expect(out).toContain("age: types.IntegerOrd('age'),") - expect(out).toContain('extractEncryptionSchemaV3') - expect(out).toContain("from '@cipherstash/stack-drizzle/v3'") + // The collapsed root: `@cipherstash/stack-drizzle` dropped its EQL v2 + // surface and folded `./v3` into `.`, de-suffixing the exports. The + // negatives matter as much as the positives — this string is written into + // the user's repo as real source, and nothing type-checks a template + // literal, so the removed names can only be caught here. + expect(out).toContain('extractEncryptionSchema(') + expect(out).toContain("from '@cipherstash/stack-drizzle'") + expect(out).not.toContain('extractEncryptionSchemaV3') + expect(out).not.toContain('@cipherstash/stack-drizzle/v3') }) it('carries no residual v2 capability vocabulary', () => { @@ -47,6 +59,7 @@ describe('generateClientFromSchemas', () => { const out = generateClientFromSchemas('supabase', schemas) expect(out).toContain("email: types.TextSearch('email'),") expect(out).toContain("from '@cipherstash/stack/v3'") + expect(out).not.toContain('EncryptionV3') expect(out).not.toContain('@cipherstash/stack-drizzle') }) diff --git a/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md b/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md index 411114a0d..7e6400669 100644 --- a/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md +++ b/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md @@ -7,7 +7,7 @@ This document is the **durable rule book** for any agent working on this codebas ## What you are working with - **Encryption client** — a per-project module that defines which tables and columns are encrypted, what data type each column holds, and which search operations are enabled. The path is in `.cipherstash/context.json` under `encryptionClientPath`. -- **EQL extension** — a Postgres extension installed via `stash eql install` that provides server-side functions for searchable encryption (`eql_v2.*`). Required before any migration that creates encrypted columns. +- **EQL extension** — installed via `stash eql install`, providing the column domains and server-side functions for searchable encryption. `stash eql install` installs **EQL v3** by default: the `public.eql_v3_*` column domains and the `eql_v3.*` functions behind them. (A project set up before v3 may instead carry the legacy `eql_v2` schema and `eql_v2_encrypted` column type; both generations decrypt, but new columns are v3.) Required before any migration that creates encrypted columns. - **Project context** — `.cipherstash/context.json` records what `stash init` discovered: integration, package manager, env key names (never values), schemas, install command, CLI version. Treat it as authoritative. - **Action plan** — `.cipherstash/setup-prompt.md` is the project-specific to-do list for the current setup run. Read it first. @@ -18,7 +18,7 @@ This document is the **durable rule book** for any agent working on this codebas 3. **Never read or echo secrets.** Env key *names* (`CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, `CS_CLIENT_KEY`, `CS_CLIENT_ACCESS_KEY`, `DATABASE_URL`) are fine to reference in code and docs. Their *values* are not. New env keys go in `.env.example` with placeholders; instruct the user to add the real value locally. Never read, `cat`, `grep`, or echo `~/.cipherstash/secretkey.json` (the development key), `~/.cipherstash/auth.json` (OAuth token and JWTs), anything under `~/.cipherstash/workspaces/`, or a value-bearing env file (`.env`, `.env.local`, `.env.production`, …). `.env.example` is the exception — it holds placeholders, not values, and you are expected to edit it. The CLI reads the credentials itself; no command needs you to open them. If a command fails on authentication, re-run `stash auth login` rather than inspecting the profile. 4. **Never invent CipherStash APIs.** If you don't know how a function is called, read the relevant skill (see below) — don't guess. The TypeScript types in `@cipherstash/stack` are the source of truth for what's callable. 5. **Never run database introspection yourself.** Don't run `psql`, `\d`, `pg_dump`, `supabase db dump`, or `drizzle-kit introspect`. The CLI already did this; the result is in `context.json`. If you need fresh introspection, ask the user to re-run `stash init`. -6. **Never modify these files.** `stash.config.ts` (generated by init — edits go in `.env`). `.cipherstash/` (CLI-owned). `~/.cipherstash/` (CLI-owned credentials — see invariant 3). The `eql_v2` schema and `eql_v2_*` functions (CLI-managed; missing function ⇒ `stash eql upgrade`, not a hand-edit). +6. **Never modify these files.** `stash.config.ts` (generated by init — edits go in `.env`). `.cipherstash/` (CLI-owned). `~/.cipherstash/` (CLI-owned credentials — see invariant 3). The EQL schemas and functions installed by the CLI — `eql_v3`/`public.eql_v3_*` today, `eql_v2`/`eql_v2_*` on a legacy install (missing function ⇒ `stash eql upgrade`, not a hand-edit). 7. **`@cipherstash/stack` must be excluded from any bundler.** The package wraps a native FFI module (`@cipherstash/protect-ffi`) that cannot be bundled. The moment you `npm install @cipherstash/stack` in a project with a bundler, configure the exclusion *before* writing any code that imports it. Concretely: Next.js needs `serverExternalPackages: ['@cipherstash/stack', '@cipherstash/protect-ffi']` in `next.config.{js,ts,mjs}`; webpack needs `externals` entries; esbuild needs `external`; Vite SSR needs `ssr.external`. Skipping this surfaces as `Cannot find module '@cipherstash/protect-ffi-*'` at runtime, often after the user has shipped to production. If you're declaring an encrypted column for the first time in a project, treat configuring this exclusion as part of the same change. ## Migrations — three phases, always reversible diff --git a/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts b/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts index e4a2e3aa1..052496cc0 100644 --- a/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts @@ -82,6 +82,28 @@ describe('SKILL_MAP', () => { expect(SKILL_MAP.postgresql).not.toContain('stash-supabase') expect(SKILL_MAP.postgresql).not.toContain('stash-prisma-next') }) + + // #754: the no-ORM path had no source for the raw-SQL binding surface or + // the WASM entry — an integration on it had to reverse-engineer both from + // `dist/*.d.ts` and the Postgres catalog. `postgresql` is that path; + // Supabase shares it (Edge Functions are the flagship WASM-entry use, and + // its migrations/RPC are hand-written SQL). + it.each([ + 'postgresql', + 'supabase', + ] as const)('%s includes the raw-SQL and edge skills', (integration) => { + expect(SKILL_MAP[integration]).toContain('stash-postgres') + expect(SKILL_MAP[integration]).toContain('stash-edge') + }) + + // The ORM integrations emit correctly-typed operands themselves, so they + // get cross-links from their own skills rather than the full install. + it.each([ + 'drizzle', + 'prisma-next', + ] as const)('%s does not install the raw-SQL skill', (integration) => { + expect(SKILL_MAP[integration]).not.toContain('stash-postgres') + }) }) describe('skillsFor', () => { diff --git a/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts b/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts index 40a011e95..1efca50e3 100644 --- a/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts @@ -5,6 +5,7 @@ import type { DataType } from '../../types.js' import { candidateDomains, defaultDomain, + isEqlEncryptedDomain, pgTypeToDataType, selectTableColumns, } from '../introspect.js' @@ -103,6 +104,44 @@ describe('defaultDomain', () => { }) }) +// The marker used to be `udt_name === 'eql_v2_encrypted'` alone. v3 is the +// default generation and its columns carry `eql_v3_*` domains, so on the +// default path an already-encrypted column was reported as plaintext: shown +// with its `dataType` hint and left unticked. `packages/wizard` already made +// this call, and its comment names the consequence — misreporting encrypted +// columns as plaintext lets an agent clobber real ciphertext. +describe('isEqlEncryptedDomain', () => { + it('recognises every eql_v3_* domain', () => { + for (const udt of [ + 'eql_v3_encrypted', + 'eql_v3_text_search', + 'eql_v3_integer_ord', + 'eql_v3_date_ord', + 'eql_v3_json', + ]) { + expect(isEqlEncryptedDomain(udt)).toBe(true) + } + }) + + it('still recognises the legacy v2 udt', () => { + expect(isEqlEncryptedDomain('eql_v2_encrypted')).toBe(true) + }) + + it('does not claim plaintext types', () => { + for (const udt of ['text', 'int4', 'jsonb', 'bool', 'timestamptz']) { + expect(isEqlEncryptedDomain(udt)).toBe(false) + } + }) + + // The trailing underscore is load-bearing: a bare `startsWith('eql_v3')` + // would also claim a hypothetical future `eql_v30_*` generation. Same + // reasoning as `classifyEqlDomain` in `@cipherstash/migrate`. + it('does not claim a future generation by prefix', () => { + expect(isEqlEncryptedDomain('eql_v30_text_search')).toBe(false) + expect(isEqlEncryptedDomain('eql_v3')).toBe(false) + }) +}) + describe('selectTableColumns', () => { beforeEach(() => { selectMock.mockReset() @@ -214,6 +253,38 @@ describe('selectTableColumns', () => { expect(schema?.columns).toEqual([{ name: 'ssn', domain: 'TextSearch' }]) }) + // The per-column hint was the literal string 'eql_v2_encrypted' for any + // encrypted column, so a v3 column was labelled with a domain it does not + // have. Report the column's actual udt. + it('hints an encrypted column with its real domain, not a hardcoded v2 udt', async () => { + const withV3 = [ + { + tableName: 'accounts', + columns: [ + { + columnName: 'ssn', + dataType: 'jsonb', + udtName: 'eql_v3_text_search', + isEqlEncrypted: true, + }, + ], + }, + ] + selectMock + .mockResolvedValueOnce('accounts') + .mockResolvedValueOnce('TextSearch') + multiselectMock.mockResolvedValueOnce(['ssn']) + + await selectTableColumns(withV3) + + const opts = multiselectMock.mock.calls[0][0].options + expect(opts).toEqual([ + expect.objectContaining({ value: 'ssn', hint: 'eql_v3_text_search' }), + ]) + // And it starts ticked, so a re-run does not offer to re-encrypt it. + expect(multiselectMock.mock.calls[0][0].initialValues).toEqual(['ssn']) + }) + it('pre-selects the widest searchable domain as the per-column default', async () => { // Asserts the ARGS to the domain prompt, not just its return: the picker // must pass initialValue: defaultDomain(options) so the widest searchable diff --git a/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts index b1b684910..ee1c302b7 100644 --- a/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts @@ -105,6 +105,117 @@ describe('renderSetupPrompt — orient + route (implement mode)', () => { expect(out).toContain('pnpm exec drizzle-kit migrate') }) + // The "wire the column through" step names the query API by hand. It used to + // name `protectOps.eq`, which exists nowhere; naming `createEncryptionOperators` + // unconditionally is the same mistake one step smaller — that symbol is + // exported only by `@cipherstash/stack-drizzle`, which a Supabase or Prisma + // project does not even have in its dependency tree. Each integration gets + // the API it can actually import. + describe('query-operator guidance is per-integration', () => { + const render = (integration: SetupPromptContext['integration']) => + renderSetupPrompt({ ...baseCtx, integration }) + + it('names the Drizzle operators for drizzle', () => { + const out = render('drizzle') + expect(out).toContain('createEncryptionOperators(client)') + expect(out).toContain('ops.eq') + }) + + it('names the wrapper builder for supabase, not the Drizzle operators', () => { + const out = render('supabase') + expect(out).not.toContain('createEncryptionOperators') + expect(out).toContain('encryptedSupabase') + }) + + it('names the eql* column operators for prisma-next', () => { + const out = render('prisma-next') + expect(out).not.toContain('createEncryptionOperators') + expect(out).toContain('eqlEq') + }) + + // `postgresql` is the default when nothing is detected, and it gets no + // integration skill at all (install-skills.ts) — so "see the integration + // skill" is a dangling pointer for it too. + it('names the core encryptQuery path for plain postgresql', () => { + const out = render('postgresql') + expect(out).not.toContain('createEncryptionOperators') + expect(out).toContain('encryptQuery') + expect(out).toContain('stash-encryption') + }) + + // `EncryptQueryOptions` takes the schema OBJECTS — `usersSchema` and + // `usersSchema.email` — not their names as strings, and `queryType` is + // inferred from the column's indexes when omitted. The object-shorthand + // `{ table, column, queryType }` reads as three required string fields: + // the same species of plausible-but-wrong API as the `protectOps.eq` this + // PR exists to have removed. + it('shows encryptQuery taking schema objects, not string names', () => { + const out = render('postgresql') + expect(out).toContain('column: usersSchema.email') + expect(out).not.toContain('{ table, column, queryType }') + }) + + // `packages/cli` builds with tsup, which transpiles without type-checking, + // and has no `typecheck` script — so `Record` buys nothing + // at build time here. An if-chain ending in a bare Drizzle `return` hands a + // future fifth integration the exact string this helper exists to stop it + // getting. Degrade to neutral guidance, as `skillsFor()` degrades to + // BASE_SKILLS. + it('degrades to neutral guidance for an unrecognised integration', () => { + const out = renderSetupPrompt({ + ...baseCtx, + integration: 'mystery-orm' as SetupPromptContext['integration'], + }) + expect(out).not.toContain('createEncryptionOperators') + expect(out).not.toContain('encryptedSupabase') + expect(out).toContain('encryptQuery') + }) + }) + + // Step 2 named the Drizzle and Supabase schema APIs unconditionally — the + // identical defect step 5 above was just fixed for. A prisma-next project was + // sent at `types.*` / `encryptedTable`, which `stash schema build` explicitly + // refuses to scaffold for that integration; a plain-Postgres project was + // given no named path at all. + describe('schema-authoring guidance is per-integration', () => { + const render = (integration: SetupPromptContext['integration']) => + renderSetupPrompt({ ...baseCtx, integration }) + + it('names the Drizzle column factories for drizzle', () => { + const out = render('drizzle') + expect(out).toContain('`types.*` column factories') + expect(out).toContain('@cipherstash/stack-drizzle') + }) + + it('names the eql_v3 domain for supabase', () => { + expect(render('supabase')).toContain('eql_v3_encrypted') + }) + + it('names the cipherstash.* field constructors for prisma-next', () => { + const out = render('prisma-next') + expect(out).toContain('cipherstash.TextSearch()') + expect(out).toContain('prisma/schema.prisma') + // The `types.*` client is precisely what `stash schema build` refuses to + // emit for prisma-next. + expect(out).not.toContain('`types.*` column factories') + }) + + it('names encryptedTable for plain postgresql', () => { + const out = render('postgresql') + expect(out).toContain('encryptedTable') + expect(out).toContain('@cipherstash/stack/v3') + }) + }) + + // `postgresql` installs no integration-specific skill (SKILL_MAP), so every + // unconditional "see the integration skill" is a pointer at a file that was + // never written. Step 5's was fixed; two more survived elsewhere. + it('never points plain postgresql at "the integration skill"', () => { + const out = renderSetupPrompt({ ...baseCtx, integration: 'postgresql' }) + expect(out).not.toMatch(/the integration skill/i) + expect(out).toContain('stash-encryption') + }) + it('emits supabase migration commands for supabase integration', () => { const out = renderSetupPrompt({ ...baseCtx, @@ -438,7 +549,12 @@ describe('renderSetupPrompt — no db push recommendations', () => { expect(out).toMatch(/1\.\s*\*\*Schema-add/) expect(out).toMatch(/2\.\s*\*\*Dual-write/) // Cutover is still covered, just without a db push workaround note. - const cutoverSection = out.substring(out.indexOf('#### Encryption cutover')) + // The heading has to be one that exists: `indexOf` returning -1 makes + // `substring(-1)` the whole document, so this scoped assertion was + // silently asserting nothing at all. + const heading = '#### Backfill and switch' + expect(out).toContain(heading) + const cutoverSection = out.substring(out.indexOf(heading)) expect(cutoverSection).toMatch(/encrypt cutover/) }) @@ -450,6 +566,66 @@ describe('renderSetupPrompt — no db push recommendations', () => { }) }) +// The rename swap is an EQL v2 operation. `stash encrypt cutover` refuses a v3 +// column outright ("Cut-over is not applicable to EQL v3 columns … there is no +// rename step", `encrypt/cutover.ts`) and refuses entirely on a v3-only +// install, where `eql_v2_configuration` does not exist. v3 is the default, so +// a template presenting the rename as the only path drafts a plan around a +// command that will decline to run. The implement prompt already splits the +// two; these templates did not. +describe('renderSetupPrompt — plan templates split EQL v3 from v2', () => { + const plan = (planStep: 'cutover' | 'complete') => + renderSetupPrompt({ ...baseCtx, mode: 'plan', planStep }) + + for (const planStep of ['cutover', 'complete'] as const) { + describe(`${planStep} plan`, () => { + it('names both versions and states v3 has no rename', () => { + const out = plan(planStep) + expect(out).toContain('EQL v3') + expect(out).toContain('EQL v2') + expect(out).toMatch(/no rename/i) + }) + + it('does not present the rename swap unconditionally', () => { + const out = plan(planStep) + // Every mention of the rename swap has to sit next to the v2 label. + for (const line of out.split('\n')) { + if (/_plaintext/.test(line)) { + expect(line).toMatch(/v2|v3/) + } + } + }) + + it('tells the agent how to determine the version per column', () => { + // The version is per-column — a database can hold both — so this + // cannot be decided once for the whole plan. + expect(plan(planStep)).toMatch(/encrypt status|encrypt backfill/) + }) + }) + } + + // The stop-and-ask trigger for "already encrypted" named the v2 UDT alone. + // v3 is the default and its columns carry `eql_v3_*` domains, so on the + // default path the check silently never fired. The agent reads the schema + // itself, so this half is fixable here; `introspect.ts`'s `isEqlEncrypted` + // has the same gap and is a separate behavioural change. + it('recognises v3 domains in the already-encrypted stop-and-ask', () => { + for (const mode of ['implement', 'plan'] as const) { + const out = renderSetupPrompt({ ...baseCtx, mode }) + expect(out).toMatch(/eql_v3_/) + } + }) + + it('keeps the cutover invocation scoped to v2', () => { + const out = plan('cutover') + const cutoverLine = out + .split('\n') + .find((l) => l.includes('encrypt cutover --table')) + expect(cutoverLine).toBeDefined() + expect(cutoverLine).toMatch(/v2/) + }) +}) + describe('renderSetupPrompt — honours what the handoff actually wrote', () => { for (const mode of ['implement', 'plan'] as const) { it(`claude-code with no skills points at neither a skills dir nor AGENTS.md (${mode})`, () => { diff --git a/packages/cli/src/commands/init/lib/install-skills.ts b/packages/cli/src/commands/init/lib/install-skills.ts index acd158180..8353ce1b1 100644 --- a/packages/cli/src/commands/init/lib/install-skills.ts +++ b/packages/cli/src/commands/init/lib/install-skills.ts @@ -12,10 +12,16 @@ import { findBundledDir } from './bundled-paths.js' */ export const SKILL_MAP: Record = { drizzle: ['stash-encryption', 'stash-drizzle', 'stash-indexing', 'stash-cli'], + // Supabase gets the raw-SQL and edge skills on top of its own: Edge + // Functions are the flagship use of the WASM entry, and Supabase projects + // write hand-written SQL in migrations and RPC even when the app itself + // goes through PostgREST (#754). supabase: [ 'stash-encryption', 'stash-supabase', 'stash-indexing', + 'stash-postgres', + 'stash-edge', 'stash-cli', ], 'prisma-next': [ @@ -24,7 +30,16 @@ export const SKILL_MAP: Record = { 'stash-indexing', 'stash-cli', ], - postgresql: ['stash-encryption', 'stash-indexing', 'stash-cli'], + // The no-ORM path: `stash-postgres` (binding + predicate forms) and `stash-edge` + // (WASM entry, CS_* credentials) are the two skills this integration has no + // other source for — everything else assumes an ORM emits the operands. + postgresql: [ + 'stash-encryption', + 'stash-indexing', + 'stash-postgres', + 'stash-edge', + 'stash-cli', + ], } /** The skills every integration gets — the safe fallback for an unmapped one. */ diff --git a/packages/cli/src/commands/init/lib/introspect.ts b/packages/cli/src/commands/init/lib/introspect.ts index 2e4a4c732..31b7bb398 100644 --- a/packages/cli/src/commands/init/lib/introspect.ts +++ b/packages/cli/src/commands/init/lib/introspect.ts @@ -42,12 +42,36 @@ export function pgTypeToDataType(udtName: string): DataType { } } +/** + * Is this column already managed by CipherStash? + * + * Both generations count. v3 is the sole generation this workspace authors, + * and its columns carry per-domain types (`eql_v3_text_search`, + * `eql_v3_integer_ord`, …) rather than v2's single `eql_v2_encrypted` udt — + * so keying on the v2 name alone, as this did, reported every column on the + * default path as plaintext. That is the dangerous direction to be wrong in: + * an encrypted column shown as plaintext invites the caller to encrypt it a + * second time. `packages/wizard` already carries this predicate; the two + * should agree. + * + * Deliberately not `classifyEqlDomain` from `@cipherstash/migrate`, despite + * the dependency being present: that answers "which generation authors this", + * and returns `null` for `eql_v2_encrypted` because v2 is no longer authorable. + * The question here is "is this encrypted at all", which v2 answers yes to. + * + * The trailing underscore matters — a bare `eql_v3` prefix would also claim a + * hypothetical future `eql_v30_*`. + */ +export function isEqlEncryptedDomain(udtName: string): boolean { + return udtName === 'eql_v2_encrypted' || udtName.startsWith('eql_v3_') +} + /** * Read every base table in the `public` schema along with its columns. * - * The `eql_v2_encrypted` UDT marker tells us a column is already managed by - * CipherStash — useful for re-runs against a partially set up DB so we can - * pre-select those columns rather than asking the user to reconfirm. + * The EQL domain markers tell us a column is already managed by CipherStash — + * useful for re-runs against a partially set up DB so we can pre-select those + * columns rather than asking the user to reconfirm. */ export async function introspectDatabase( databaseUrl: string, @@ -85,7 +109,7 @@ export async function introspectDatabase( columnName: row.column_name, dataType: row.data_type, udtName: row.udt_name, - isEqlEncrypted: row.udt_name === 'eql_v2_encrypted', + isEqlEncrypted: isEqlEncryptedDomain(row.udt_name), }) tableMap.set(row.table_name, cols) } @@ -203,7 +227,7 @@ export function defaultDomain( * Returns `undefined` if the user cancels at any prompt — callers should * propagate the cancellation rather than treating it as "no columns selected". * - * Pre-selects columns that are already `eql_v2_encrypted` so re-running on a + * Pre-selects columns that already carry an EQL domain so re-running on a * partially encrypted DB is a no-op by default. */ export async function selectTableColumns( @@ -230,7 +254,7 @@ export async function selectTableColumns( if (eqlColumns.length > 0) { p.log.info( - `Detected ${eqlColumns.length} column${eqlColumns.length !== 1 ? 's' : ''} with eql_v2_encrypted type — pre-selected for you.`, + `Detected ${eqlColumns.length} already-encrypted column${eqlColumns.length !== 1 ? 's' : ''} (${[...new Set(eqlColumns.map((c) => c.udtName))].join(', ')}) — pre-selected for you.`, ) } @@ -239,7 +263,7 @@ export async function selectTableColumns( options: table.columns.map((col) => ({ value: col.columnName, label: col.columnName, - hint: col.isEqlEncrypted ? 'eql_v2_encrypted' : col.dataType, + hint: col.isEqlEncrypted ? col.udtName : col.dataType, })), required: true, initialValues: eqlColumns.map((c) => c.columnName), diff --git a/packages/cli/src/commands/init/lib/setup-prompt.ts b/packages/cli/src/commands/init/lib/setup-prompt.ts index ae3fa5aa3..3deefd3f3 100644 --- a/packages/cli/src/commands/init/lib/setup-prompt.ts +++ b/packages/cli/src/commands/init/lib/setup-prompt.ts @@ -68,6 +68,94 @@ function migrationCommands( return undefined } +/** + * Where this integration's rules were actually written. + * + * `postgresql` is the fallback integration and installs no integration-specific + * skill (see `SKILL_MAP` in `install-skills.ts`), so "the integration skill" is + * a pointer at a file that was never created. Point it at `stash-encryption`, + * which it does get. + */ +function integrationSkillRef(integration: Integration): string { + switch (integration) { + case 'drizzle': + case 'supabase': + case 'prisma-next': + return 'the integration skill' + default: + return 'the `stash-encryption` skill' + } +} + +/** + * How this integration declares an encrypted column in the schema. + * + * Per-integration for the same reason the query operators are: the APIs are not + * interchangeable, and `stash schema build` (`utils.ts`) refuses outright to + * scaffold a `types.*` client for prisma-next — that integration authors its + * columns with `cipherstash.*` constructors in `schema.prisma` instead. + */ +function schemaAuthoringGuidance(integration: Integration): string { + switch (integration) { + case 'drizzle': + return 'Declare it with the `types.*` column factories from `@cipherstash/stack-drizzle` on the existing `pgTable`' + case 'supabase': + return 'Declare the column with the `eql_v3_encrypted` domain in the migration SQL (`encryptedSupabase` derives its encryption config by introspecting those domains)' + case 'prisma-next': + return 'Declare the field with the `cipherstash.*` constructors in `prisma/schema.prisma` (`cipherstash.TextSearch()`, `cipherstash.DoubleOrd()`, …), with the `cipherstash` extension pack wired up per `@cipherstash/prisma-next/control`' + default: + return 'Declare the table with `encryptedTable` and the `types.*` domain factories from `@cipherstash/stack/v3`, then pass it to `Encryption({ schemas })`' + } +} + +/** + * How this integration filters on an encrypted column. Named per-integration + * rather than generically because the APIs are not interchangeable and only one + * of them is importable from any given project: `createEncryptionOperators` is + * exported by `@cipherstash/stack-drizzle` alone, so naming it for a Supabase + * or Prisma project sends the agent after a package that is not installed. + * + * A `switch` with a neutral `default`, not an if-chain ending in the Drizzle + * string: `packages/cli` is built by tsup, which transpiles without + * type-checking, and the package has no `typecheck` script — so nothing would + * catch a fifth `Integration` variant silently inheriting Drizzle's answer. + * `skillsFor()` in `install-skills.ts` degrades the same way, for the same + * reason. + */ +function queryOperatorGuidance(integration: Integration): string { + switch (integration) { + case 'supabase': + return 'query paths filter through the `encryptedSupabase` wrapper (`es.from("users").select(...).eq("email", value)`) — it encrypts filter operands for encrypted columns automatically; see the integration skill' + case 'prisma-next': + return 'query paths use the `eql*` operators on the column inside `.where()` (`u.email.eqlEq(value)`, `eqlMatch`, `eqlGt`, …) — see the integration skill' + case 'drizzle': + return 'query paths use the right operator (`ops.eq`, from `createEncryptionOperators(client)`) — see the integration skill' + default: + // `table` and `column` are the schema OBJECTS, not their names as + // strings, and `queryType` is inferred from the column's configured + // indexes unless it is passed to override the inference. + return 'query paths encrypt the search term first — `client.encryptQuery(value, { table: usersSchema, column: usersSchema.email })`, passing the schema objects themselves rather than their names — and compare that against the encrypted column; see the `stash-encryption` skill (a plain Postgres project gets no integration-specific skill)' + } +} + +/** + * How this integration turns ciphertext back into values on the read path. + * + * Named per-integration for the third time in this file, and for the third + * time because the answer is not portable: `decryptModel` is the typed + * client's, transparent decryption is the Supabase wrapper's. + */ +function readPathGuidance(integration: Integration): string { + switch (integration) { + case 'supabase': + return 'selects through the `encryptedSupabase` wrapper decrypt transparently' + case 'prisma-next': + return 'the encrypted fields decrypt through the Prisma Next client' + default: + return 'call `decryptModel(row, usersSchema)` — or `bulkDecryptModels` for a set — before returning the value to callers' + } +} + function bullet(line: string): string { return `- ${line}` } @@ -110,6 +198,10 @@ const SKILL_PURPOSES: Record = { 'Prisma Next-specific patterns: `cipherstash.*` field constructors, migration flow, encrypted query operators', 'stash-indexing': 'index recipes for encrypted columns — the `eql_v3` extractor functional indexes, Supabase/managed-Postgres constraints, EXPLAIN verification', + 'stash-postgres': + 'hand-written Postgres SQL over `pg` / `postgres-js`: the encrypted predicate matrix, `eql_v3.query_*` operand casts, per-driver parameter binding', + 'stash-edge': + 'the `@cipherstash/stack/wasm-inline` entry for Deno / Supabase Edge Functions / Workers: imports, `CS_*` credentials, the credential-identity rule', 'stash-dynamodb': 'DynamoDB encryption: per-item encrypt/decrypt, HMAC attribute keys, audit logging', 'stash-cli': @@ -274,13 +366,13 @@ export function renderImplementPrompt(ctx: SetupPromptContext): string { '', '### Add a new encrypted column', '', - 'Use when the column **does not yet exist** in the database (no plaintext predecessor to preserve). This is normal Drizzle / Supabase work plus the encryption client patterns from the integration skill.', + `Use when the column **does not yet exist** in the database (no plaintext predecessor to preserve). This is normal schema work in the project's own ORM or migration tooling, plus the encryption client patterns from ${integrationSkillRef(ctx.integration)}.`, '', "1. **If this is the first encrypted column in the project, configure the bundler exclusion first.** `@cipherstash/stack` cannot be bundled (it wraps a native FFI module). Next.js: add `serverExternalPackages: ['@cipherstash/stack', '@cipherstash/protect-ffi']` to `next.config.*`. Webpack: `externals`. esbuild: `external`. Vite SSR: `ssr.external`. Without this, the encryption client crashes at runtime with `Cannot find module '@cipherstash/protect-ffi-*'`. See the `stash-encryption` skill's Installation section for the full snippets.", - "2. Edit the user's real schema file (`src/db/schema.ts` or wherever they keep it) to declare the new encrypted column. Use the patterns in the integration skill — `encryptedType` for Drizzle, `encryptedColumn` for Supabase. Encrypted columns must be **nullable `jsonb`** at creation time. Never `.notNull()`.", + `2. Edit the user's real schema file (\`src/db/schema.ts\` or wherever they keep it) to declare the new encrypted column. ${schemaAuthoringGuidance(ctx.integration)} — the patterns are in ${integrationSkillRef(ctx.integration)}. Encrypted columns must be **nullable \`jsonb\`** at creation time (the \`eql_v3_*\` domains are over \`jsonb\`). Never \`.notNull()\`.`, `3. Generate the schema migration${migration ? ` — \`${migration.generate}\` (${migration.tool})` : " using the project's existing migration tooling"}.`, `4. Show the user the generated SQL before applying${migration ? ` — \`${migration.apply}\`` : ''}.`, - '5. Wire the column through the application code: insert paths encrypt before write, select paths decrypt after read, query paths use the right operator (`protectOps.eq`, etc. — see the integration skill).', + `5. Wire the column through the application code: insert paths encrypt before write, select paths decrypt after read, ${queryOperatorGuidance(ctx.integration)}.`, '6. Verify with a round-trip: insert a record, select it back, confirm the value decrypts and the search ops work.', '', '### Migrate an existing column to encrypted', @@ -303,8 +395,8 @@ export function renderImplementPrompt(ctx: SetupPromptContext): string { '#### Backfill and switch — after dual-writes are live', '', `3. **Backfill.** Run \`${cli} encrypt backfill --table --column \`. The CLI prompts the user (or accepts \`--confirm-dual-writes-deployed\` non-interactively) to confirm dual-writes are live, then chunks through the existing rows. Resumable; checkpoints to \`cs_migrations\` after every chunk. SIGINT-safe.`, - `4. **Switch reads to the encrypted column.** The step depends on the EQL version (\`${cli} encrypt backfill\` prints it; \`${cli} encrypt status\` shows it). **EQL v3 (the default):** there is no rename — update the schema and queries to read/write the encrypted column by its own name, and wire decryption through the encryption client. **EQL v2:** update the schema file to declare the encrypted column under its final name (drop the twin suffix, switch \`\` to \`encryptedType\`), then \`${cli} encrypt cutover --table --column \` runs the rename in one transaction (\`\` → \`_plaintext\`, twin → \`\`).`, - '5. **Wire the read path through the encryption client.** The read column now holds ciphertext. Read code paths must decrypt before returning the value to callers — `decryptModel(row, table)` for Drizzle, the `encryptedSupabase` wrapper for Supabase, or the equivalent `decrypt`/`bulkDecryptModels` calls. Without this step, your read paths return raw encrypted payloads to end users. The integration skill has the exact API.', + `4. **Switch reads to the encrypted column.** The step depends on the EQL version (\`${cli} encrypt backfill\` prints it; \`${cli} encrypt status\` shows it). **EQL v3 (the default):** there is no rename — update the schema and queries to read/write the encrypted column by its own name, and wire decryption through the encryption client. **EQL v2 (legacy data only):** update the schema file to declare the encrypted column under its final name (drop the twin suffix), then \`${cli} encrypt cutover --table --column \` runs the rename in one transaction (\`\` → \`_plaintext\`, twin → \`\`). Do **not** declare a v2 column with a \`types.*\` domain — those are EQL v3 only. The adapters no longer author v2 (\`@cipherstash/stack-drizzle\` removed \`encryptedType\`), so a v2 column is a read path: declare it with the deprecated \`@cipherstash/stack/schema\` builders and decrypt through \`@cipherstash/stack\`.`, + `5. **Wire the read path through the encryption client.** The read column now holds ciphertext — ${readPathGuidance(ctx.integration)}. Without this step, your read paths return raw encrypted payloads to end users. See ${integrationSkillRef(ctx.integration)} for the exact API.`, '6. **Remove the dual-write code.** The plaintext column (still `` on v3; renamed `_plaintext` on v2) is no longer authoritative. Delete the dual-write logic from the persistence layer.', `7. **Drop.** Run \`${cli} encrypt drop --table --column \`. Generates a migration that removes the now-unused plaintext column (on v3 it first verifies no rows are still plaintext-only). Apply with the project's normal migration tooling.`, '', @@ -326,7 +418,7 @@ export function renderImplementPrompt(ctx: SetupPromptContext): string { "The user asks to convert a populated column in place. Explain why it doesn't work and offer the migrate-existing-column flow instead.", ), bullet( - "A column the user names is already encrypted (`eql_v2_encrypted` udt) but with a different EQL config than they've described. This is the post-cutover re-encryption case (`stash encrypt update`, not yet shipped) — surface it instead of guessing.", + "A column the user names is already encrypted — an `eql_v3_*` domain (`eql_v3_text_search`, `eql_v3_integer_ord`, …) on the default path, or the legacy `eql_v2_encrypted` udt — but with a different EQL config than they've described. This is the post-cutover re-encryption case (`stash encrypt update`, not yet shipped) — surface it instead of guessing.", ), bullet( 'The schema migration would change the data type of a column the user has already filled.', @@ -428,7 +520,7 @@ function planSharedStopAndAsk(): string[] { "The user asks to convert a populated column in place. Explain why it doesn't work and offer the migrate-existing-column flow instead.", ), bullet( - "A column the user names is already encrypted (`eql_v2_encrypted` udt) but with a different EQL config than they've described. This is the post-cutover re-encryption case (`stash encrypt update`, not yet shipped) — surface it in the plan as a flagged risk.", + "A column the user names is already encrypted — an `eql_v3_*` domain (`eql_v3_text_search`, `eql_v3_integer_ord`, …) on the default path, or the legacy `eql_v2_encrypted` udt — but with a different EQL config than they've described. This is the post-cutover re-encryption case (`stash encrypt update`, not yet shipped) — surface it in the plan as a flagged risk.", ), bullet( 'You discover existing partial CipherStash setup that disagrees with what the user is describing — someone else may have run `stash init` earlier with different choices. Note this in the plan and ask the user to clarify before writing prescriptive steps.', @@ -556,19 +648,16 @@ function renderCutoverPlanPrompt(ctx: SetupPromptContext): string { '**Backfill.** Encrypt the historical rows that pre-date the rollout deploy. Resumable; chunked; SIGINT-safe.', ), bullet( - '**Schema rename.** Update the schema declaration so the original column points at the encrypted type.', - ), - bullet( - '**Cutover.** A single transaction renames `` → `_plaintext` and `_encrypted` → ``.', + `**Switch reads to the encrypted column.** This step depends on the column's EQL version, so establish it per column first — \`${cli} encrypt backfill\` prints it and \`${cli} encrypt status\` shows it. **EQL v3 (the default):** there is no rename. Update the schema declaration and the queries to read and write \`_encrypted\` under its own name. **EQL v2 (legacy data only):** declare the encrypted column under its final name (drop the twin suffix), then a single \`${cli} encrypt cutover\` transaction renames \`\` → \`_plaintext\` and \`_encrypted\` → \`\`.`, ), bullet( - '**Read path.** Application reads of `` now return ciphertext until the read path decrypts via the encryption client. The plan must specify what changes per read site.', + '**Read path.** Reads of the encrypted column return ciphertext until the read path decrypts via the encryption client. The plan must specify what changes per read site.', ), bullet( - '**Remove dual-writes.** The plaintext column is now `_plaintext` and is no longer authoritative. Delete the dual-write code paths.', + '**Remove dual-writes.** The plaintext column — still `` on v3, renamed `_plaintext` on v2 — is no longer authoritative. Delete the dual-write code paths.', ), bullet( - "**Drop plaintext.** `stash encrypt drop` emits a migration that removes `_plaintext`. Apply with the project's normal migration tooling.", + `**Drop plaintext.** \`${cli} encrypt drop\` emits a migration that removes the now-unused plaintext column; on v3 it first verifies no rows are still plaintext-only. Apply with the project's normal migration tooling.`, ), '', '## Your task: produce the cutover plan file', @@ -591,12 +680,12 @@ function renderCutoverPlanPrompt(ctx: SetupPromptContext): string { ' encrypt backfill` invocation with concrete `--table` / `--column` values.', ), bullet( - 'The schema-edit step, with the exact rename pattern (drop `_encrypted` suffix on the encrypted column, switch the original column declaration off `text`/`varchar` and onto the encrypted type).', + 'The schema-edit step, stated per column against its EQL version. **v3:** point the declaration and queries at `_encrypted` under its own name — there is no rename, and no `_encrypted` suffix to drop. **v2:** drop the `_encrypted` suffix and switch the original column declaration off `text`/`varchar` and onto the encrypted type.', ), bullet( - 'The cutover invocation per column: `' + + 'For EQL v2 columns only, the cutover invocation per column: `' + cli + - ' encrypt cutover --table --column `.', + ' encrypt cutover --table --column `. It refuses v3 columns — there is nothing to rename — so do not schedule it for them.', ), bullet( 'Read-path code changes: every site that reads `` from this table must decrypt via the encryption client. Enumerate the sites you can find via grep so the user can verify nothing was missed.', @@ -608,7 +697,7 @@ function renderCutoverPlanPrompt(ctx: SetupPromptContext): string { ' encrypt drop --table --column `, plus the schema-migration apply step that follows.', ), bullet( - `Risks specific to cutover: row-count for the backfill (use \`${cli} eql status\` to estimate if helpful), tables under heavy write load (cutover holds a brief lock on the rename), application code that constructs SQL by string (those reads won't transparently decrypt).`, + `Risks specific to cutover: row-count for the backfill (use \`${cli} eql status\` to estimate if helpful), tables under heavy write load (on v2, cutover holds a brief lock for the rename), application code that constructs SQL by string (those reads won't transparently decrypt).`, ), bullet( "Open questions for the user — anything you can't determine from the schema, context.json, or the skills.", @@ -653,7 +742,7 @@ function renderCompletePlanPrompt(ctx: SetupPromptContext): string { '**Add new encrypted columns** — declared encrypted from the start; single-deploy.', ), bullet( - '**Migrate existing columns** — schema-add → dual-write code → backfill → schema rename → cutover → read-path switch → remove dual-write code → drop plaintext. No deploy gate between rollout and cutover steps because there is no deployed application to gate on.', + `**Migrate existing columns** — schema-add → dual-write code → backfill → switch reads to the encrypted column → remove dual-write code → drop plaintext. The switch step depends on the column's EQL version (\`${cli} encrypt status\` shows it): on **EQL v3**, the default, there is no rename — point the schema and queries at \`_encrypted\` by name; on **EQL v2** only, \`${cli} encrypt cutover\` renames the twin into \`\` first. No deploy gate between rollout and cutover steps because there is no deployed application to gate on.`, ), '', '## Your task: produce the complete-rollout plan file', diff --git a/packages/cli/src/commands/init/steps/install-eql.ts b/packages/cli/src/commands/init/steps/install-eql.ts index 9ee6bd880..31c9e5b9f 100644 --- a/packages/cli/src/commands/init/steps/install-eql.ts +++ b/packages/cli/src/commands/init/steps/install-eql.ts @@ -104,13 +104,12 @@ export const installEqlStep: InitStep = { // --drizzle`) rather than routing through `eql install`. // // `eql install --drizzle` is v2-only — under the v3 default it rejects the - // flag outright, so init used to pin `eqlVersion: '2'` to get a migration - // at all. That pin made `stash init --drizzle` the one flow that provisions - // a v2 database while every other integration (and a bare `stash eql - // install`) gets v3, and it contradicted the stash-drizzle skill we install - // into the very same project — that skill documents the `/v3` surface - // (`types.*` domains, `EncryptionV3`) and would have the user's agent - // author v3 code against a v2 database. + // flag outright, so routing Drizzle through it would provision a v2 + // database while every other integration (and a bare `stash eql install`) + // gets v3. That also contradicts the stash-drizzle skill installed into the + // very same project, which documents the v3 surface (`types.*` domains, + // `Encryption`) and would have the user's agent author v3 code against a v2 + // database. // // `stash eql migration --drizzle` (added in #691) closes that gap: v3 SQL, // still migration-first, and it bundles the `cs_migrations` tracking schema diff --git a/packages/cli/src/commands/init/utils.ts b/packages/cli/src/commands/init/utils.ts index 3045433d7..9750af055 100644 --- a/packages/cli/src/commands/init/utils.ts +++ b/packages/cli/src/commands/init/utils.ts @@ -279,18 +279,18 @@ ${columnDefs.join('\n')} createdAt: timestamp('created_at').defaultNow(), }) -const ${schemaVarName} = extractEncryptionSchemaV3(${varName})` +const ${schemaVarName} = extractEncryptionSchema(${varName})` }) const schemaVarNames = schemas.map((s) => `${toCamelCase(s.tableName)}Schema`) return `import { pgTable, integer, timestamp } from 'drizzle-orm/pg-core' -import { types, extractEncryptionSchemaV3 } from '@cipherstash/stack-drizzle/v3' -import { EncryptionV3 } from '@cipherstash/stack/v3' +import { types, extractEncryptionSchema } from '@cipherstash/stack-drizzle' +import { Encryption } from '@cipherstash/stack/v3' ${tableDefs.join('\n\n')} -export const encryptionClient = await EncryptionV3({ +export const encryptionClient = await Encryption({ schemas: [${schemaVarNames.join(', ')}], }) ` @@ -311,11 +311,11 @@ ${columnDefs.join('\n')} const tableVarNames = schemas.map((s) => `${toCamelCase(s.tableName)}Table`) - return `import { EncryptionV3, encryptedTable, types } from '@cipherstash/stack/v3' + return `import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3' ${tableDefs.join('\n\n')} -export const encryptionClient = await EncryptionV3({ +export const encryptionClient = await Encryption({ schemas: [${tableVarNames.join(', ')}], }) ` @@ -366,7 +366,7 @@ export function generateClientFromSchemas( * schemas yet, and explicitly tells the agent that the user's existing * schema files remain authoritative. The agent's job during the handoff * is to declare encrypted columns directly in those files and update the - * `EncryptionV3({ schemas: [...] })` call below to reference them. + * `Encryption({ schemas: [...] })` call below to reference them. */ export function generatePlaceholderClient(integration: Integration): string { if (integration === 'drizzle') { @@ -381,15 +381,17 @@ const DRIZZLE_PLACEHOLDER = `/** * \`stash init\` wrote this file. It is intentionally NOT a real Drizzle * schema. Your existing schema files (typically under \`src/db/\`) remain * authoritative — your agent will edit those directly when you encrypt a - * column, then update the \`EncryptionV3({ schemas: [...] })\` call below + * column, then update the \`Encryption({ schemas: [...] })\` call below * to reference the encrypted tables you declared there. * - * Until that happens, the encryption client is initialised with no - * schemas, and \`stash encrypt\` commands will surface a clear error - * pointing at this file. + * Until that happens, the encryption client is initialised with a single + * placeholder table so that this file compiles, and \`stash db push\`, + * \`stash db validate\` and \`stash encrypt backfill\` refuse to run and point + * back here. (\`stash encrypt cutover\` / \`drop\` resolve against the database + * and never read this file.) * * This project uses EQL v3. Encrypted columns are concrete Postgres domains - * built with the \`types.*\` factories from \`@cipherstash/stack-drizzle/v3\`. + * built with the \`types.*\` factories from \`@cipherstash/stack-drizzle\`. * Each domain's query capabilities are FIXED by the type you pick — there is * no capability config object. Choose the factory whose capabilities you need: * types.Text / types.Integer / … storage only (encrypt/decrypt, no queries) @@ -404,7 +406,7 @@ const DRIZZLE_PLACEHOLDER = `/** * Encrypted twin column for an existing populated column (path 3 — lifecycle): * * import { pgTable, integer, text } from 'drizzle-orm/pg-core' - * import { types } from '@cipherstash/stack-drizzle/v3' + * import { types } from '@cipherstash/stack-drizzle' * * export const users = pgTable('users', { * id: integer('id').primaryKey().generatedAlwaysAsIdentity(), @@ -419,19 +421,27 @@ const DRIZZLE_PLACEHOLDER = `/** * billing_address: types.TextEq('billing_address'), * }) * - * Once you have encrypted tables declared, harvest them and pass to EncryptionV3(): + * Once you have encrypted tables declared, harvest them and pass to Encryption(): * - * import { extractEncryptionSchemaV3 } from '@cipherstash/stack-drizzle/v3' - * import { EncryptionV3 } from '@cipherstash/stack/v3' + * import { extractEncryptionSchema } from '@cipherstash/stack-drizzle' + * import { Encryption } from '@cipherstash/stack/v3' * import { users, orders } from './db/schema' * - * export const encryptionClient = await EncryptionV3({ - * schemas: [extractEncryptionSchemaV3(users), extractEncryptionSchemaV3(orders)], + * export const encryptionClient = await Encryption({ + * schemas: [extractEncryptionSchema(users), extractEncryptionSchema(orders)], * }) */ -import { EncryptionV3 } from '@cipherstash/stack/v3' +import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3' + +// REPLACE THIS. It exists only so this file compiles before you have declared +// any encrypted tables — \`Encryption\` requires at least one. Swap it for your +// real tables (see the patterns above); \`stash db push\`, \`stash db validate\` +// and \`stash encrypt backfill\` refuse to run while the placeholder is still here. +export const placeholderTable = encryptedTable('__stash_placeholder__', { + replace_me: types.Text('replace_me'), +}) -export const encryptionClient = await EncryptionV3({ schemas: [] }) +export const encryptionClient = await Encryption({ schemas: [placeholderTable] }) ` const GENERIC_PLACEHOLDER = `/** @@ -440,11 +450,13 @@ const GENERIC_PLACEHOLDER = `/** * \`stash init\` wrote this file. It is intentionally NOT a real schema * definition. Your existing schema files remain authoritative — your * agent will declare encrypted columns there and update the - * \`EncryptionV3({ schemas: [...] })\` call below to reference them. + * \`Encryption({ schemas: [...] })\` call below to reference them. * - * Until that happens, the encryption client is initialised with no - * schemas, and \`stash encrypt\` commands will surface a clear error - * pointing at this file. + * Until that happens, the encryption client is initialised with a single + * placeholder table so that this file compiles, and \`stash db push\`, + * \`stash db validate\` and \`stash encrypt backfill\` refuse to run and point + * back here. (\`stash encrypt cutover\` / \`drop\` resolve against the database + * and never read this file.) * * This project uses EQL v3. Encrypted columns are concrete Postgres domains * built with the \`types.*\` factories from \`@cipherstash/stack/eql/v3\` @@ -474,16 +486,24 @@ const GENERIC_PLACEHOLDER = `/** * billing_address: types.TextEq('billing_address'), * }) * - * Once you have encrypted tables declared, pass them to EncryptionV3(): + * Once you have encrypted tables declared, pass them to Encryption(): * - * import { EncryptionV3 } from '@cipherstash/stack/v3' + * import { Encryption } from '@cipherstash/stack/v3' * import { users, orders } from './db/schema' * - * export const encryptionClient = await EncryptionV3({ + * export const encryptionClient = await Encryption({ * schemas: [users, orders], * }) */ -import { EncryptionV3 } from '@cipherstash/stack/v3' +import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3' + +// REPLACE THIS. It exists only so this file compiles before you have declared +// any encrypted tables — \`Encryption\` requires at least one. Swap it for your +// real tables (see the patterns above); \`stash db push\`, \`stash db validate\` +// and \`stash encrypt backfill\` refuse to run while the placeholder is still here. +export const placeholderTable = encryptedTable('__stash_placeholder__', { + replace_me: types.Text('replace_me'), +}) -export const encryptionClient = await EncryptionV3({ schemas: [] }) +export const encryptionClient = await Encryption({ schemas: [placeholderTable] }) ` diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts index 7103d7baa..4442c43e0 100644 --- a/packages/cli/src/config/index.ts +++ b/packages/cli/src/config/index.ts @@ -231,12 +231,62 @@ export async function loadEncryptConfig( process.exit(1) } - const config = encryptClient.getEncryptConfig() + return requireUsableEncryptConfig( + encryptClient.getEncryptConfig(), + encryptClientPath, + ) +} + +/** + * Refuse an encryption client that cannot drive any command yet, naming the + * cause. + * + * Shared rather than duplicated because it guards ONE file reached by two + * loaders — `loadEncryptConfig` for `stash db push` / `db validate`, and + * `loadEncryptionContext` for `stash encrypt backfill`. When the copies were + * separate they had already drifted on the nullish-config case, so one command + * named the cause while the other fell through to `requireTable`'s `Table + * "users" was not found … Available: (none)` — the symptom-not-cause message + * this guard exists to replace (#787 review follow-up). + * + * Both refusals are hard exits: there is no partially-usable state here, and + * every caller would otherwise have to re-derive that. + */ +export function requireUsableEncryptConfig( + config: EncryptConfig | undefined, + encryptClientPath: string, +): EncryptConfig { if (!config) { console.error( `Error: Encryption client in ${encryptClientPath} has no initialized encrypt config.`, ) process.exit(1) } + + // `stash init` scaffolds a client holding one placeholder table, because + // `Encryption` requires a non-empty schema set and the scaffold has no real + // tables to name yet. Reaching here with only that table means the user never + // replaced it. + // + // Read from the built encrypt config, never from the module's export map: a + // scaffold whose `export` keyword was dropped is still un-replaced, while a + // stale `export const placeholderTable` beside real tables that are imported + // rather than re-exported is not (#787 review). + const tables = Object.keys(config.tables ?? {}) + if (tables.length === 1 && tables[0] === PLACEHOLDER_TABLE_NAME) { + console.error( + `Error: ${encryptClientPath} still contains the placeholder table \`${PLACEHOLDER_TABLE_NAME}\` that \`stash init\` wrote.\n\nDeclare your encrypted columns and pass those tables to Encryption({ schemas: [...] }) in that file, then re-run this command.`, + ) + process.exit(1) + } + return config } + +/** + * The table name `stash init`'s scaffold uses so the file it writes compiles. + * + * Kept in sync with the templates in `commands/init/utils.ts` by + * `__tests__/placeholder-client-fixture.test.ts`, which also typechecks them. + */ +export const PLACEHOLDER_TABLE_NAME = '__stash_placeholder__' diff --git a/packages/cli/tsconfig.scaffold.json b/packages/cli/tsconfig.scaffold.json new file mode 100644 index 000000000..1a11f6286 --- /dev/null +++ b/packages/cli/tsconfig.scaffold.json @@ -0,0 +1,25 @@ +{ + // Compiles the exact files `stash init` writes into a user's project. + // + // Nothing else in the repo did. `packages/cli` has no typecheck script (21 + // pre-existing errors), the codegen tests only string-match the templates, + // and `build-schema.test.ts` stubs the generator out entirely — so when + // `Encryption` was tightened to require a non-empty schema set, both + // placeholders started emitting a file that fails `tsc` on first build, in a + // file the CLI had just told the user not to hand-edit, and CI stayed green + // (#772 review, finding 6). + // + // Deliberately scoped to the fixtures so it gates the scaffold without + // waiting on the rest of the package to compile. + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ESNext", + "lib": ["ESNext"], + "strict": true, + "skipLibCheck": true + }, + "include": ["__fixtures__/scaffold/*.ts"] +} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 53df05899..76b3903f4 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -9,6 +9,30 @@ export default defineConfig({ resolve: { alias: { '@/': `${resolve(__dirname, './src')}/`, + // `@cipherstash/migrate` publishes `./dist` only, so importing it — or + // spreading `importOriginal()` inside a partial `vi.mock` — makes the + // UNIT suite require a prior workspace build. CI only hides that because + // turbo runs `^build` first; without this alias and without a build, 10 + // files / 177 tests fail to collect with `Failed to resolve entry for + // package "@cipherstash/migrate"` — the transitive `src` importers + // (`status/index.ts`, `db/install.ts`, `encrypt/lib/db-readers.ts`), not + // just the one mocked test. A full-factory `vi.mock` does not help: + // Vite's import analysis fails on the source module's import statement + // before mocking runs. + // + // This removes the `@cipherstash/migrate` build coupling ONLY. The suite + // is still not self-contained: removing `packages/stack/dist` still + // fails 10 files, via TWO independent routes — + // 1. `packages/migrate/src/backfill.ts` imports `@cipherstash/stack`, + // so this alias reaches it transitively; and + // 2. `init/lib/__tests__/introspect.test.ts` imports + // `@cipherstash/stack/eql/v3` directly, never touching migrate. + // Route 2 means decoupling `backfill.ts` would NOT make the suite + // standalone. Closing both needs `stackSourceAlias`, which cannot be + // spread here — its `'@/'` entry (pointing at `packages/stack/src`) + // would clobber this package's own `'@/'`. That is why + // `vitest.shared.ts` is not imported by this config (#787 review). + '@cipherstash/migrate': resolve(__dirname, '../migrate/src/index.ts'), }, }, }) diff --git a/packages/migrate/README.md b/packages/migrate/README.md index ea1fb6549..bd41337ff 100644 --- a/packages/migrate/README.md +++ b/packages/migrate/README.md @@ -6,7 +6,7 @@ Backs the `stash encrypt` CLI command group, but also exported for direct use ## Lifecycle -Each column walks through these phases — the ladder depends on the column's EQL version (auto-detected from its Postgres domain type via `detectColumnEqlVersion`): +Each column walks through these phases — the ladder depends on the column's EQL version, and detection is one-sided: `detectColumnEqlVersion` recognises an `eql_v3_*` Postgres domain as **v3**, and everything else as *unknown* (`null`). The v2 ladder is the fallback for an unknown column, not a detection result: ```text EQL v2: schema-added → dual-writing → backfilling → backfilled → cut-over → dropped @@ -43,7 +43,7 @@ Creates `cipherstash.cs_migrations` idempotently. Normally called by `stash eql Chunked, resumable, idempotent backfill of plaintext → encrypted. Per chunk, in a single transaction: select next page → encrypt via `client.bulkEncryptModels` → `UPDATE … FROM (VALUES …)` → `INSERT` a `backfill_checkpoint` event. Guards with `encrypted IS NULL` so re-runs never double-write. - `db`: a `pg.PoolClient` (the runner drives transactions on it). -- `encryptionClient`: your initialised `@cipherstash/stack` client (or anything that exposes `bulkEncryptModels(models, table)` returning `{ data } | { failure }`). For an EQL v3 column pass an `EncryptionV3` client (from `@cipherstash/stack/v3`) — it pins the v3 wire format; the engine itself is version-agnostic and writes whatever envelope the client produces. +- `encryptionClient`: your initialised `@cipherstash/stack` client (or anything that exposes `bulkEncryptModels(models, table)` returning `{ data } | { failure }`). For an EQL v3 column pass an `Encryption` client (from `@cipherstash/stack/v3`) — it pins the v3 wire format; the engine itself is version-agnostic and writes whatever envelope the client produces. - `tableSchema`: the `EncryptedTable` for the target table from your encryption client file. - `signal`: optional `AbortSignal`. If aborted between chunks, the backfill exits cleanly and leaves a resumable checkpoint. @@ -59,7 +59,7 @@ Thin wrappers around `eql_v2.rename_encrypted_columns()` (the **v2** cut-over pr ### `detectColumnEqlVersion` / `resolveEncryptedColumn` / `listEncryptedColumns` / `classifyEqlDomain` -The EQL types are self-describing, and these are the domain-type primitives everything version-specific above branches on. `detectColumnEqlVersion(client, table, column)` inspects one column's Postgres domain type and returns `2`, `3`, or `null` (not an EQL column); resolution is case-exact (quoted-identifier semantics, matching the rest of the pipeline) and honours `search_path`. `resolveEncryptedColumn(client, table, plaintextColumn, hint?)` finds a plaintext column's encrypted counterpart from the domain types — an explicit hint (e.g. the manifest's recorded `encryptedColumn`) wins, then the `_encrypted` convention, then the table's sole EQL column; the name is never assumed. `listEncryptedColumns` returns every EQL-domain column on a table, classified. +The EQL types are self-describing, and these are the domain-type primitives everything version-specific above branches on. `detectColumnEqlVersion(client, table, column)` inspects one column's Postgres domain type and returns `3` (an `eql_v3_*` domain) or `null` — it never returns `2`. `null` means *unknown*: a plaintext column, or a legacy `eql_v2_encrypted` one, is indistinguishable here, and callers fall through to the v2 lifecycle (a v2 column's version is carried by the manifest's recorded `eqlVersion`, if it has one). Resolution is case-exact (quoted-identifier semantics, matching the rest of the pipeline) and honours `search_path`. `resolveEncryptedColumn(client, table, plaintextColumn, hint?)` finds a plaintext column's encrypted counterpart from the domain types — an explicit hint (e.g. the manifest's recorded `encryptedColumn`) wins, then the `_encrypted` convention, then the table's sole EQL column; the name is never assumed. `listEncryptedColumns` returns every EQL-domain column on a table, classified. ### `countEncrypted` / `countUnencrypted` diff --git a/packages/migrate/package.json b/packages/migrate/package.json index b14343caa..c96f2f9db 100644 --- a/packages/migrate/package.json +++ b/packages/migrate/package.json @@ -42,6 +42,7 @@ }, "scripts": { "build": "tsup", + "typecheck": "tsc --noEmit -p tsconfig.json", "dev": "tsup --watch", "test": "vitest run", "lint": "biome check ." diff --git a/packages/migrate/src/__tests__/version.test.ts b/packages/migrate/src/__tests__/version.test.ts index b6cfdb9d6..140b12738 100644 --- a/packages/migrate/src/__tests__/version.test.ts +++ b/packages/migrate/src/__tests__/version.test.ts @@ -2,6 +2,7 @@ import type { ClientBase } from 'pg' import { describe, expect, it, vi } from 'vitest' import { classifyEqlDomain, + columnExists, detectColumnEqlVersion, type EncryptedColumnInfo, listEncryptedColumns, @@ -18,8 +19,13 @@ function mockClient(rows: Array>) { } describe('classifyEqlDomain', () => { - it('maps eql_v2_encrypted to 2', () => { - expect(classifyEqlDomain('eql_v2_encrypted')).toBe(2) + it('no longer classifies eql_v2_encrypted — v3 is the sole authored generation', () => { + // The v2 branch was removed: this workspace authors/backfills v3 only, so + // the domain classifier recognises `eql_v3_*` alone. A legacy v2 column's + // version now comes from the manifest's recorded `eqlVersion`, not here. + // (Existing v2 ciphertext stays decryptable — only classification of v2 as + // an authorable generation is dropped.) + expect(classifyEqlDomain('eql_v2_encrypted')).toBeNull() }) it('maps any eql_v3_* domain to 3', () => { @@ -46,10 +52,20 @@ describe('classifyEqlDomain', () => { describe('detectColumnEqlVersion', () => { it('classifies from the domain type', async () => { - const { client } = mockClient([{ domain_name: 'eql_v2_encrypted' }]) + const { client } = mockClient([{ domain_name: 'eql_v3_text_search' }]) expect( await detectColumnEqlVersion(client, 'users', 'email_encrypted'), - ).toBe(2) + ).toBe(3) + }) + + it('returns null for a legacy eql_v2_encrypted domain (v2 no longer classified)', async () => { + // A v2 column still exists physically, but the classifier no longer treats + // it as an authorable EQL generation — callers fall back to the manifest's + // recorded eqlVersion. + const { client } = mockClient([{ domain_name: 'eql_v2_encrypted' }]) + expect( + await detectColumnEqlVersion(client, 'users', 'ssn_encrypted'), + ).toBeNull() }) it('returns null for a plaintext column (base type, not a domain)', async () => { @@ -88,16 +104,17 @@ describe('detectColumnEqlVersion', () => { }) describe('listEncryptedColumns', () => { - it('returns only EQL-domain columns, classified', async () => { + it('returns only EQL v3-domain columns, classified (legacy v2 domains excluded)', async () => { const { client } = mockClient([ { column: 'id', domain_name: 'int8' }, { column: 'email', domain_name: 'text' }, { column: 'email_enc', domain_name: 'eql_v3_text_search' }, + // A legacy v2 column is no longer classified as EQL, so it drops out of + // the encrypted-column listing entirely. { column: 'ssn_encrypted', domain_name: 'eql_v2_encrypted' }, ]) expect(await listEncryptedColumns(client, 'users')).toEqual([ { column: 'email_enc', domain: 'eql_v3_text_search', version: 3 }, - { column: 'ssn_encrypted', domain: 'eql_v2_encrypted', version: 2 }, ]) }) }) @@ -153,10 +170,10 @@ describe('pickEncryptedColumn', () => { }) it('never resolves the plaintext column to itself', () => { - // Post-cutover v2: `email` itself carries the v2 domain. It is the - // ciphertext, not a counterpart of itself. + // A column that IS the plaintext argument cannot be its own encrypted + // counterpart, even when it's the table's sole EQL-domain column. expect( - pickEncryptedColumn([col('email', 'eql_v2_encrypted', 2)], 'email'), + pickEncryptedColumn([col('email', 'eql_v3_encrypted')], 'email'), ).toBeNull() }) @@ -186,3 +203,36 @@ describe('resolveEncryptedColumn', () => { }) }) }) + +describe('columnExists', () => { + it('returns true when the catalog reports the column', async () => { + const { client } = mockClient([{ exists: true }]) + expect(await columnExists(client, 'users', 'ssn_encrypted')).toBe(true) + }) + + it('returns false when it does not', async () => { + const { client } = mockClient([{ exists: false }]) + expect(await columnExists(client, 'users', 'gone')).toBe(false) + }) + + it('preserves identifier case — no bare to_regclass($1)', async () => { + // Same guard as `detectColumnEqlVersion` above. A bare `to_regclass($1)` + // parses its argument and case-folds it, so a Prisma-style `"User"` table + // silently resolves to `user` and the probe reports "column missing" for a + // column that plainly exists. In `resolveColumnLifecycle` that turns the + // #772 fail-closed into a silent no-op (#787 review). + const { client, query } = mockClient([{ exists: true }]) + await columnExists(client, 'User', 'ssn_encrypted') + const [sql, values] = query.mock.calls[0] as [string, unknown[]] + expect(sql).toContain("format('%I'") + expect(sql).not.toMatch(/to_regclass\(\$1\)/) + expect(values).toEqual(['User', null, 'ssn_encrypted']) + }) + + it('splits schema-qualified names on the first dot, like qualifyTable', async () => { + const { client, query } = mockClient([{ exists: true }]) + await columnExists(client, 'app.users', 'ssn_encrypted') + const [, values] = query.mock.calls[0] as [string, unknown[]] + expect(values).toEqual(['users', 'app', 'ssn_encrypted']) + }) +}) diff --git a/packages/migrate/src/index.ts b/packages/migrate/src/index.ts index 65bd0db15..026b5f6e5 100644 --- a/packages/migrate/src/index.ts +++ b/packages/migrate/src/index.ts @@ -73,6 +73,7 @@ export { } from './state.js' export { classifyEqlDomain, + columnExists, detectColumnEqlVersion, type EncryptedColumnInfo, type EncryptedColumnResolution, diff --git a/packages/migrate/src/version.ts b/packages/migrate/src/version.ts index dccfe7e35..88c496170 100644 --- a/packages/migrate/src/version.ts +++ b/packages/migrate/src/version.ts @@ -17,7 +17,7 @@ export type EqlVersion = 2 | 3 export interface EncryptedColumnInfo { /** The column's name, exactly as Postgres reports it. */ column: string - /** The EQL domain name, e.g. `eql_v2_encrypted` or `eql_v3_text_search`. */ + /** The EQL domain name, e.g. `eql_v3_text_search` or `eql_v3_integer_ord`. */ domain: string version: EqlVersion } @@ -30,12 +30,19 @@ export interface EncryptedColumnInfo { * rule lives, and why detection never relies on column NAMES: the * `_encrypted` naming is a convention, neither enforced nor required. * - * - `eql_v2_encrypted` → 2 * - `eql_v3_*` (e.g. `eql_v3_text_search`, `eql_v3_integer_ord`) → 3 - * - anything else → `null` (not an EQL column) + * - anything else → `null` (not an EQL v3 column) + * + * v3 is the sole generation this workspace authors and backfills, so the + * classifier only recognises `eql_v3_*` domains. A legacy `eql_v2_encrypted` + * column is therefore no longer classified here — its version is carried by + * the manifest's recorded `eqlVersion` instead (see the `?? eqlVersion` + * fallbacks in the CLI's `encrypt status` / `status` renderers). Existing v2 + * ciphertext stays decryptable; only its *classification as an authorable + * generation* is dropped. `EqlVersion` keeps the `2` member for those + * manifest-sourced legacy values. */ export function classifyEqlDomain(domain: string): EqlVersion | null { - if (domain === 'eql_v2_encrypted') return 2 // Underscore included: a bare `startsWith('eql_v3')` would also claim // hypothetical future generations like `eql_v30_*`. if (domain.startsWith('eql_v3_')) return 3 @@ -98,6 +105,38 @@ export async function detectColumnEqlVersion( return domain === undefined ? null : classifyEqlDomain(domain) } +/** + * Whether `columnName` exists on `tableName` at all, whatever its type. + * + * Distinct from {@link detectColumnEqlVersion}, which answers "and is it an EQL + * column?". Callers need the difference to tell a STALE reference (the column + * is gone) from a live one the classifier simply does not recognise — most + * often a legacy `eql_v2_encrypted` counterpart. + * + * Lives here rather than in the CLI so it shares {@link REGCLASS_SQL} with the + * other catalog probes: a hand-rolled `to_regclass($1)` case-folds unquoted + * identifiers and silently reports "missing" for a Prisma-style `"User"` table + * (#787 review). + */ +export async function columnExists( + client: ClientBase, + tableName: string, + columnName: string, +): Promise { + const { schema, table } = splitTableName(tableName) + const { rows } = await client.query<{ exists: boolean }>( + `SELECT EXISTS ( + SELECT 1 FROM pg_attribute + WHERE attrelid = ${REGCLASS_SQL} + AND attname = $3 + AND attnum > 0 + AND NOT attisdropped + ) AS exists`, + [table, schema, columnName], + ) + return rows[0]?.exists === true +} + /** * Every EQL-domain column on a table, classified. The EQL types are * self-describing, so this is the ground truth for "which columns on this diff --git a/packages/migrate/tsconfig.json b/packages/migrate/tsconfig.json index 982c07850..f479fb74d 100644 --- a/packages/migrate/tsconfig.json +++ b/packages/migrate/tsconfig.json @@ -19,5 +19,10 @@ "paths": { "@/*": ["./src/*"] } - } + }, + // The `typecheck` gate must compile source, not build output. TypeScript's + // default `exclude` covers `node_modules` but NOT `dist`, and specifying + // `exclude` replaces that default — hence both. Enforced by + // `pnpm run lint:typecheck-scope`. + "exclude": ["dist", "node_modules"] } diff --git a/packages/nextjs/__tests__/nextjs.test.ts b/packages/nextjs/__tests__/nextjs.test.ts index c15f05b52..e03df244c 100644 --- a/packages/nextjs/__tests__/nextjs.test.ts +++ b/packages/nextjs/__tests__/nextjs.test.ts @@ -1,6 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' // cts.test.ts -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, type Mock, vi } from 'vitest' // --------------------------------------------- // 1) Mock next/headers before importing it @@ -65,7 +65,7 @@ describe('getCtsToken', () => { accessToken: 'fake_token', expiry: 999999, } - ;(cookies as unknown as vi.Mock).mockReturnValueOnce({ + ;(cookies as unknown as Mock).mockReturnValueOnce({ get: vi.fn().mockReturnValue({ value: JSON.stringify(mockCookieValue) }), }) @@ -78,7 +78,7 @@ describe('getCtsToken', () => { }) it('should return null if the cookie is not present', async () => { - ;(cookies as unknown as vi.Mock).mockReturnValueOnce({ + ;(cookies as unknown as Mock).mockReturnValueOnce({ get: vi.fn().mockReturnValue(undefined), }) diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index c318db4c1..df6e8a461 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -1,7 +1,7 @@ { "name": "@cipherstash/nextjs", "version": "4.1.1", - "description": "Nextjs package for use with @cipherstash/protect", + "description": "Nextjs package for use with @cipherstash/stack", "keywords": [ "encrypted", "typescript", @@ -33,6 +33,7 @@ }, "scripts": { "build": "tsup", + "typecheck": "tsc --noEmit -p tsconfig.json", "dev": "tsup --watch", "release": "tsup" }, diff --git a/packages/nextjs/tsconfig.json b/packages/nextjs/tsconfig.json index 6da81c927..ad991f9c5 100644 --- a/packages/nextjs/tsconfig.json +++ b/packages/nextjs/tsconfig.json @@ -24,5 +24,10 @@ "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false - } + }, + // The `typecheck` gate must compile source, not build output. TypeScript's + // default `exclude` covers `node_modules` but NOT `dist`, and specifying + // `exclude` replaces that default — hence both. Enforced by + // `pnpm run lint:typecheck-scope`. + "exclude": ["dist", "node_modules"] } diff --git a/packages/prisma-next/src/stack/from-stack-v3.ts b/packages/prisma-next/src/stack/from-stack-v3.ts index 28ab70452..071446b4d 100644 --- a/packages/prisma-next/src/stack/from-stack-v3.ts +++ b/packages/prisma-next/src/stack/from-stack-v3.ts @@ -25,7 +25,7 @@ */ import type { AnyV3Table } from '@cipherstash/stack/eql/v3' -import type { ClientConfig } from '@cipherstash/stack/types' +import type { V3ClientConfig } from '@cipherstash/stack/types' import { EncryptionV3, type TypedEncryptionClient } from '@cipherstash/stack/v3' import type { SqlMiddleware, @@ -55,8 +55,14 @@ export interface CipherstashFromStackV3Options { */ readonly schemasV3?: ReadonlyArray - /** Pass-through to `EncryptionV3({ config })` (keyset overrides, logging, …). */ - readonly encryptionConfig?: ClientConfig + /** + * Pass-through to `EncryptionV3({ config })` (keyset overrides, logging, …). + * + * `V3ClientConfig`, not `ClientConfig`: this package is EQL v3 only, and the + * legacy `eqlVersion: 2` escape hatch throws at setup over the all-v3 schema + * set this entry point derives. + */ + readonly encryptionConfig?: V3ClientConfig } export interface CipherstashFromStackV3Result { diff --git a/packages/protect-dynamodb/.npmignore b/packages/protect-dynamodb/.npmignore deleted file mode 100644 index 3490e24db..000000000 --- a/packages/protect-dynamodb/.npmignore +++ /dev/null @@ -1,5 +0,0 @@ -.env -.turbo -node_modules -cipherstash.secret.toml -cipherstash.toml \ No newline at end of file diff --git a/packages/protect-dynamodb/CHANGELOG.md b/packages/protect-dynamodb/CHANGELOG.md deleted file mode 100644 index 6be07a477..000000000 --- a/packages/protect-dynamodb/CHANGELOG.md +++ /dev/null @@ -1,213 +0,0 @@ -# @cipherstash/protect-dynamodb - -## 12.0.2-rc.0 - -### Patch Changes - -- @cipherstash/protect@12.0.2-rc.0 - -## 12.0.1 - -### Patch Changes - -- aa9c4b1: Documentation: refresh package READMEs after the protectjs → stack repository rename. Fixed repository and license links, replaced dead in-repo docs links with cipherstash.com/docs URLs, rewrote the incorrect @cipherstash/nextjs README, and added guidance pointing new projects to @cipherstash/stack. -- Updated dependencies [aa9c4b1] - - @cipherstash/protect@12.0.1 - -## 12.0.0 - -### Patch Changes - -- f743fcc: Upgrade `@cipherstash/protect-ffi` to `0.23.0` and the bundled CipherStash EQL extension to `eql-2.3.1`. - - Breaking upstream changes adopted in this release: - - - **Encrypt-config schema version**: `buildEncryptConfig` now emits `{ v: 1, ... }` (was `{ v: 2, ... }`). protect-ffi `0.22.0` started validating this field and rejects any value other than `1` with the new `UNSUPPORTED_CONFIG_VERSION` error code. - - **Storage and query payloads are now distinct types** (protect-ffi `0.23.0`): the previously-conflated `Encrypted` type splits into `Encrypted` (storage-only, `c` required) and a new `EncryptedQuery` (search terms — scalar `unique`/`match`/`ore` lookups and `ste_vec_selector` JSON path queries; no `c`). JSON containment queries (`ste_vec_term`) still return a storage-shaped `Encrypted` payload. `encryptQuery` / `encryptQueryBulk` now return `Encrypted | EncryptedQuery`, and the stack's `EncryptedSearchTerm` / `EncryptedQueryResult` unions widen to match. `decrypt` rejects query payloads at the type level. The DynamoDB `SearchTermsOperation` narrows via `'hm' in term` rather than `term.hm`. - - **SteVec encoding default flipped**: protect-ffi's default `mode` for `ste_vec` indexes changed from `compat` to `standard`. The two encodings are not cross-compatible. Existing JSON-searchable data that was indexed under `compat` will need to be re-encrypted to be queryable. The stack adopts the new `standard` default — there is no longer a way to pin `compat` from the SDK. - - **EQL extension bumped to `eql-2.3.1`**: the new SteVec `standard` encoding requires matching support in the database EQL extension. The CLI's bundled SQL (`packages/cli/src/sql/*.sql`) and the `@cipherstash/prisma-next` install bundle (`migrations/20260601T0000_install_eql_bundle/ops.json` + `eql-install.generated.ts`) are updated to `eql-2.3.1`. Databases installed with an older EQL extension must be reinstalled (`stash db install`) before containment / contained-by queries against SteVec columns will work. `eql-2.3.1` ships the `_encrypted_check_c` fix for SteVec storage payloads ([cipherstash/encrypt-query-language#232](https://github.com/cipherstash/encrypt-query-language/issues/232)). - - **New error codes**: `ProtectErrorCode` (re-exported from `@cipherstash/protect-ffi`) gains `MATCH_REQUIRES_TEXT` and `UNSUPPORTED_CONFIG_VERSION`. Exhaustive switches over `ProtectErrorCode` will need additional cases. - - **`match` index validation**: protect-ffi now rejects `match` indexes on columns whose `cast_as` is not text-family (`'text'` / `'string'`) with `MATCH_REQUIRES_TEXT`. The stack's `freeTextSearch()` builder is unaffected because it only targets string-typed columns. - - **`Encrypted` ciphertext shape**: protect-ffi's `Encrypted` type is now a discriminated union keyed on `k` (`'ct'` for scalars, `'sv'` for SteVec). SteVec storage payloads now place the root document ciphertext at `sv[0].c`. The stack's `isEncryptedPayload` runtime check continues to work because storage payloads still carry `c` (scalar) or `sv` (SteVec). The DynamoDB helpers (`toEncryptedDynamoItem`, `SearchTermsOperation`) now narrow on `k` before reading variant-only fields. - - **Config-validation error message wording**: error messages for config-validation failures now come from upstream `ConfigError`. `ProtectError.code` values are preserved; consumers that string-match on `err.message` for config-validation errors must update. - -- Updated dependencies [f743fcc] - - @cipherstash/protect@12.0.0 - -## 11.0.2 - -### Patch Changes - -- Updated dependencies [a8dbb65] - - @cipherstash/protect@11.1.2 - -## 11.0.1 - -### Patch Changes - -- Updated dependencies [afe6810] - - @cipherstash/protect@11.1.1 - -## 11.0.0 - -### Patch Changes - -- Updated dependencies [068f820] - - @cipherstash/protect@11.1.0 - -## 10.0.0 - -### Patch Changes - -- Updated dependencies [b0e56b8] - - @cipherstash/protect@10.6.0 - -## 9.0.0 - -### Patch Changes - -- Updated dependencies [db72e2c] -- Updated dependencies [e769740] - - @cipherstash/protect@10.5.0 - -## 8.0.0 - -### Patch Changes - -- Updated dependencies [9ccaf68] - - @cipherstash/protect@10.4.0 - -## 7.0.0 - -### Patch Changes - -- Updated dependencies [a1fce2b] -- Updated dependencies [622b684] - - @cipherstash/protect@10.3.0 - -## 6.0.1 - -### Patch Changes - -- @cipherstash/protect@10.2.1 - -## 6.0.0 - -### Patch Changes - -- Updated dependencies [de029de] - - @cipherstash/protect@10.2.0 - -## 5.1.1 - -### Patch Changes - -- Updated dependencies [ff4421f] - - @cipherstash/protect@10.1.1 - -## 5.1.0 - -### Patch Changes - -- Updated dependencies [6b87c17] - - @cipherstash/protect@10.1.0 - -## 5.0.2 - -### Patch Changes - -- @cipherstash/protect@10.0.2 - -## 5.0.1 - -### Patch Changes - -- @cipherstash/protect@10.0.1 - -## 5.0.0 - -### Major Changes - -- 788dbfc: Added JSON and INT data type support and update FFI to v0.17.1 with x86_64 musl environment platform support. - - - Update @cipherstash/protect-ffi from 0.16.0 to 0.17.1 with support for x86_64 musl platforms. - - Add searchableJson() method to schema for JSON field indexing (the search operations still don't work but this interface exists) - - Refactor type system: EncryptedPayload → Encrypted, add JsPlaintext - - Add comprehensive test suites for JSON, integer, and basic encryption - - Update encryption format to use 'k' property for searchable JSON - - Remove deprecated search terms tests for JSON fields - - Simplify schema data types to text, int, json only - - Update model helpers to handle new encryption format - - Fix type safety issues in bulk operations and model encryption - -### Patch Changes - -- Updated dependencies [788dbfc] - - @cipherstash/protect@10.0.0 - -## 4.0.0 - -### Patch Changes - -- Updated dependencies [c7ed7ab] -- Updated dependencies [211e979] - - @cipherstash/protect@9.6.0 - -## 3.0.0 - -### Minor Changes - -- 6f45b02: Fully implemented audit metadata functionality. - -### Patch Changes - -- Updated dependencies [6f45b02] - - @cipherstash/protect@9.5.0 - -## 2.0.1 - -### Patch Changes - -- @cipherstash/protect@9.4.1 - -## 2.0.0 - -### Patch Changes - -- Updated dependencies [1cc4772] - - @cipherstash/protect@9.4.0 - -## 1.0.0 - -### Minor Changes - -- 01fed9e: Added audit support for all protect and protect-dynamodb interfaces. - -### Patch Changes - -- Updated dependencies [01fed9e] - - @cipherstash/protect@9.3.0 - -## 0.3.0 - -### Minor Changes - -- 2b63ee1: Support nested protect schema in dynamodb helper functions. -- e33fbaf: Fixed bug when handling schema definitions without an equality flag. - -## 0.2.0 - -### Minor Changes - -- 5fc0150: Fix build and publish. - -## 1.0.0 - -### Minor Changes - -- c8468ee: Released initial version of the DynamoDB helper interface. - -### Patch Changes - -- Updated dependencies [c8468ee] - - @cipherstash/protect@9.1.0 diff --git a/packages/protect-dynamodb/README.md b/packages/protect-dynamodb/README.md deleted file mode 100644 index 1489f336a..000000000 --- a/packages/protect-dynamodb/README.md +++ /dev/null @@ -1,358 +0,0 @@ -# CipherStash DynamoDB Helpers - -Helpers for using [CipherStash encryption](https://github.com/cipherstash/stack) with DynamoDB. - -> [!TIP] -> For new projects we recommend [`@cipherstash/stack`](https://www.npmjs.com/package/@cipherstash/stack), which provides this functionality as `encryptedDynamoDB` from `@cipherstash/stack/dynamodb`. See the [DynamoDB docs](https://cipherstash.com/docs/stack/cipherstash/encryption/dynamodb). This package documents the legacy `@cipherstash/protect` API. - -[![Built by CipherStash](https://raw.githubusercontent.com/cipherstash/meta/refs/heads/main/csbadge.svg)](https://cipherstash.com) -[![NPM version](https://img.shields.io/npm/v/@cipherstash/protect-dynamodb.svg?style=for-the-badge&labelColor=000000)](https://www.npmjs.com/package/@cipherstash/protect-dynamodb) -[![License](https://img.shields.io/npm/l/@cipherstash/protect-dynamodb.svg?style=for-the-badge&labelColor=000000)](https://github.com/cipherstash/stack/blob/main/LICENSE.md) - -## Installation - -```bash -npm install @cipherstash/protect-dynamodb -# or -yarn add @cipherstash/protect-dynamodb -# or -pnpm add @cipherstash/protect-dynamodb -``` - -## Quick Start - -```typescript -import { protectDynamoDB } from '@cipherstash/protect-dynamodb' -import { protect, csColumn, csTable } from '@cipherstash/protect' -import { PutCommand, GetCommand } from '@aws-sdk/lib-dynamodb' - -// Define your protected table schema -const users = csTable('users', { - email: csColumn('email').equality(), -}) - -// Initialize the Protect client -const protectClient = await protect({ - schemas: [users], -}) - -// Create the DynamoDB helper instance -const protectDynamo = protectDynamoDB({ - protectClient, -}) - -// Encrypt and store a user -const user = { - email: 'user@example.com', -} - -const encryptResult = await protectDynamo.encryptModel(user, users) -if (encryptResult.failure) { - throw new Error(`Failed to encrypt user: ${encryptResult.failure.message}`) -} - -// Store in DynamoDB -await docClient.send(new PutCommand({ - TableName: 'Users', - Item: encryptResult.data, -})) - -// Create search terms for querying -const searchTermsResult = await protectDynamo.createSearchTerms([ - { - value: 'user@example.com', - column: users.email, - table: users, - }, -]) - -if (searchTermsResult.failure) { - throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) -} - -// Query using the search term -const [emailHmac] = searchTermsResult.data -const result = await docClient.send(new GetCommand({ - TableName: 'Users', - Key: { email__hmac: emailHmac }, -})) - -if (!result.Item) { - throw new Error('Item not found') -} - -// Decrypt the result -const decryptResult = await protectDynamo.decryptModel( - result.Item, - users, -) - -if (decryptResult.failure) { - throw new Error(`Failed to decrypt user: ${decryptResult.failure.message}`) -} - -const decryptedUser = decryptResult.data -``` - -## Features - -### Encryption and Decryption - -The package provides methods to encrypt and decrypt data for DynamoDB: - -- `encryptModel`: Encrypts a single model -- `bulkEncryptModels`: Encrypts multiple models in bulk -- `decryptModel`: Decrypts a single model -- `bulkDecryptModels`: Decrypts multiple models in bulk - -All methods return a `Result` type that must be checked for failures: - -```typescript -const result = await protectDynamo.encryptModel(user, users) -if (result.failure) { - // Handle error - console.error(result.failure.message) -} else { - // Use encrypted data - const encryptedData = result.data -} -``` - -### Search Terms - -Create search terms for querying encrypted data: - -- `createSearchTerms`: Creates search terms for one or more columns - -```typescript -const searchTermsResult = await protectDynamo.createSearchTerms([ - { - value: 'user@example.com', - column: users.email, - table: users, - }, -]) - -if (searchTermsResult.failure) { - throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) -} - -const [emailHmac] = searchTermsResult.data -``` - -### DynamoDB Integration - -The package automatically handles: -- Converting encrypted data to DynamoDB's format -- Adding HMAC attributes for searchable fields -- Preserving unencrypted fields -- Converting DynamoDB items back to encrypted format for decryption - -## Usage Patterns - -### Simple Table with Encrypted Fields - -```typescript -const users = csTable('users', { - email: csColumn('email').equality(), -}) - -// Encrypt and store -const encryptResult = await protectDynamo.encryptModel({ - pk: 'user#1', - email: 'user@example.com', -}, users) - -if (encryptResult.failure) { - throw new Error(`Failed to encrypt user: ${encryptResult.failure.message}`) -} - -// Query using search terms -const searchTermsResult = await protectDynamo.createSearchTerms([ - { - value: 'user@example.com', - column: users.email, - table: users, - }, -]) - -if (searchTermsResult.failure) { - throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) -} -``` - -### Encrypted Partition Key - -```typescript -// Table with encrypted partition key -const table = { - TableName: 'Users', - AttributeDefinitions: [ - { - AttributeName: 'email__hmac', - AttributeType: 'S', - }, - ], - KeySchema: [ - { - AttributeName: 'email__hmac', - KeyType: 'HASH', - }, - ], -} - -// Create search terms for querying -const searchTermsResult = await protectDynamo.createSearchTerms([ - { - value: 'user@example.com', - column: users.email, - table: users, - }, -]) - -if (searchTermsResult.failure) { - throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) -} - -const [emailHmac] = searchTermsResult.data -``` - -### Encrypted Sort Key - -```typescript -// Table with encrypted sort key -const table = { - TableName: 'Users', - AttributeDefinitions: [ - { - AttributeName: 'pk', - AttributeType: 'S', - }, - { - AttributeName: 'email__hmac', - AttributeType: 'S', - }, - ], - KeySchema: [ - { - AttributeName: 'pk', - KeyType: 'HASH', - }, - { - AttributeName: 'email__hmac', - KeyType: 'RANGE', - }, - ], -} - -// Create search terms for querying -const searchTermsResult = await protectDynamo.createSearchTerms([ - { - value: 'user@example.com', - column: users.email, - table: users, - }, -]) - -if (searchTermsResult.failure) { - throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) -} - -const [emailHmac] = searchTermsResult.data -``` - -### Global Secondary Index with Encrypted Key - -```typescript -// Table with GSI using encrypted key -const table = { - TableName: 'Users', - AttributeDefinitions: [ - { - AttributeName: 'pk', - AttributeType: 'S', - }, - { - AttributeName: 'email__hmac', - AttributeType: 'S', - }, - ], - KeySchema: [ - { - AttributeName: 'pk', - KeyType: 'HASH', - }, - ], - GlobalSecondaryIndexes: [ - { - IndexName: 'EmailIndex', - KeySchema: [ - { - AttributeName: 'email__hmac', - KeyType: 'HASH', - }, - ], - Projection: { - ProjectionType: 'INCLUDE', - NonKeyAttributes: ['email__source'], - }, - }, - ], -} - -// Create search terms for querying -const searchTermsResult = await protectDynamo.createSearchTerms([ - { - value: 'user@example.com', - column: users.email, - table: users, - }, -]) - -if (searchTermsResult.failure) { - throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) -} - -const [emailHmac] = searchTermsResult.data -``` - -## Error Handling - -All methods return a `Result` type from `@byteslice/result` that must be checked for failures: - -```typescript -const result = await protectDynamo.encryptModel(user, users) - -if (result.failure) { - // Handle error - console.error(result.failure.message) -} else { - // Use encrypted data - const encryptedData = result.data -} -``` - -## Type Safety - -The package is fully typed and supports TypeScript: - -```typescript -type User = { - pk: string - email: string -} - -// Type-safe encryption -const encryptResult = await protectDynamo.encryptModel(user, users) -if (encryptResult.failure) { - throw new Error(`Failed to encrypt user: ${encryptResult.failure.message}`) -} -const encryptedUser = encryptResult.data - -// Type-safe decryption -const decryptResult = await protectDynamo.decryptModel(item, users) -if (decryptResult.failure) { - throw new Error(`Failed to decrypt user: ${decryptResult.failure.message}`) -} -const decryptedUser = decryptResult.data -``` diff --git a/packages/protect-dynamodb/__tests__/audit.test.ts b/packages/protect-dynamodb/__tests__/audit.test.ts deleted file mode 100644 index 619ff8754..000000000 --- a/packages/protect-dynamodb/__tests__/audit.test.ts +++ /dev/null @@ -1,316 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable, csValue, protect } from '@cipherstash/protect' -import { beforeAll, describe, expect, it } from 'vitest' -import { protectDynamoDB } from '../src' - -const schema = csTable('dynamo_cipherstash_test', { - email: csColumn('email').equality(), - firstName: csColumn('firstName').equality(), - lastName: csColumn('lastName').equality(), - phoneNumber: csColumn('phoneNumber'), - example: { - protected: csValue('example.protected'), - deep: { - protected: csValue('example.deep.protected'), - }, - }, -}) - -describe('protect dynamodb helpers', () => { - let protectClient: Awaited> - let protectDynamo: ReturnType - - beforeAll(async () => { - protectClient = await protect({ - schemas: [schema], - }) - - protectDynamo = protectDynamoDB({ - protectClient, - }) - }) - - it('should encrypt columns', async () => { - const testData = { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test.user@example.com', - address: '123 Main Street', - createdAt: '2024-08-15T22:14:49.948Z', - firstName: 'John', - lastName: 'Smith', - phoneNumber: '555-555-5555', - companyName: 'Acme Corp', - batteryBrands: ['Brand1', 'Brand2'], - metadata: { role: 'admin' }, - example: { - protected: 'hello world', - notProtected: 'I am not protected', - deep: { - protected: 'deep protected', - notProtected: 'deep not protected', - }, - }, - } - - const result = await protectDynamo.encryptModel(testData, schema).audit({ - metadata: { sub: 'cj@cjb.io', type: 'dynamo_encrypt_model' }, - }) - if (result.failure) { - throw new Error(`Encryption failed: ${result.failure.message}`) - } - - const encryptedData = result.data - - // Verify equality columns are encrypted - expect(encryptedData).toHaveProperty('email__source') - expect(encryptedData).toHaveProperty('email__hmac') - expect(encryptedData).toHaveProperty('firstName__source') - expect(encryptedData).toHaveProperty('firstName__hmac') - expect(encryptedData).toHaveProperty('lastName__source') - expect(encryptedData).toHaveProperty('lastName__hmac') - expect(encryptedData).toHaveProperty('phoneNumber__source') - expect(encryptedData).not.toHaveProperty('phoneNumber__hmac') - expect(encryptedData.example).toHaveProperty('protected__source') - expect(encryptedData.example.deep).toHaveProperty('protected__source') - - // Verify other fields remain unchanged - expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX') - expect(encryptedData.address).toBe('123 Main Street') - expect(encryptedData.createdAt).toBe('2024-08-15T22:14:49.948Z') - expect(encryptedData.companyName).toBe('Acme Corp') - expect(encryptedData.batteryBrands).toEqual(['Brand1', 'Brand2']) - expect(encryptedData.example.notProtected).toBe('I am not protected') - expect(encryptedData.example.deep.notProtected).toBe('deep not protected') - expect(encryptedData.metadata).toEqual({ role: 'admin' }) - }) - - it('should handle null and undefined values', async () => { - const testData = { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: null, - firstName: undefined, - lastName: 'Smith', - phoneNumber: null, - metadata: { role: null }, - example: { - protected: null, - notProtected: 'I am not protected', - deep: { - protected: undefined, - notProtected: 'deep not protected', - }, - }, - } - - const result = await protectDynamo.encryptModel(testData, schema).audit({ - metadata: { sub: 'cj@cjb.io', type: 'dynamo_encrypt_model' }, - }) - if (result.failure) { - throw new Error(`Encryption failed: ${result.failure.message}`) - } - - const encryptedData = result.data - - // Verify null/undefined equality columns are handled - expect(encryptedData).toHaveProperty('lastName__source') - expect(encryptedData).toHaveProperty('lastName__hmac') - - // Verify other fields remain unchanged - expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX') - expect(encryptedData.phoneNumber).toBeNull() - expect(encryptedData.email).toBeNull() - expect(encryptedData.firstName).toBeUndefined() - expect(encryptedData.metadata).toEqual({ role: null }) - expect(encryptedData.example.protected).toBeNull() - expect(encryptedData.example.deep.protected).toBeUndefined() - expect(encryptedData.example.deep.notProtected).toBe('deep not protected') - }) - - it('should handle empty strings and special characters', async () => { - const testData = { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: '', - firstName: 'John!@#$%^&*()', - lastName: 'Smith ', - phoneNumber: '', - metadata: { role: 'admin!@#$%^&*()' }, - } - - const result = await protectDynamo.encryptModel(testData, schema).audit({ - metadata: { sub: 'cj@cjb.io', type: 'dynamo_encrypt_model' }, - }) - if (result.failure) { - throw new Error(`Encryption failed: ${result.failure.message}`) - } - - const encryptedData = result.data - - // Verify equality columns are encrypted - expect(encryptedData).toHaveProperty('email__source') - expect(encryptedData).toHaveProperty('email__hmac') - expect(encryptedData).toHaveProperty('firstName__source') - expect(encryptedData).toHaveProperty('firstName__hmac') - expect(encryptedData).toHaveProperty('lastName__source') - expect(encryptedData).toHaveProperty('lastName__hmac') - expect(encryptedData).toHaveProperty('phoneNumber__source') - expect(encryptedData).not.toHaveProperty('phoneNumber__hmac') - - // Verify other fields remain unchanged - expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX') - expect(encryptedData.metadata).toEqual({ role: 'admin!@#$%^&*()' }) - }) - - it('should handle bulk encryption', async () => { - const testData = [ - { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test1@example.com', - firstName: 'John', - lastName: 'Smith', - phoneNumber: '555-555-5555', - }, - { - id: '02ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test2@example.com', - firstName: 'Jane', - lastName: 'Doe', - phoneNumber: '555-555-5556', - }, - ] - - const result = await protectDynamo - .bulkEncryptModels(testData, schema) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'dynamo_bulk_encrypt_models', - }, - }) - if (result.failure) { - throw new Error(`Bulk encryption failed: ${result.failure.message}`) - } - - const encryptedData = result.data - - // Verify both items are encrypted - expect(encryptedData).toHaveLength(2) - - // Verify first item - expect(encryptedData[0]).toHaveProperty('email__source') - expect(encryptedData[0]).toHaveProperty('email__hmac') - expect(encryptedData[0]).toHaveProperty('firstName__source') - expect(encryptedData[0]).toHaveProperty('firstName__hmac') - expect(encryptedData[0]).toHaveProperty('lastName__source') - expect(encryptedData[0]).toHaveProperty('lastName__hmac') - expect(encryptedData[0]).toHaveProperty('phoneNumber__source') - - // Verify second item - expect(encryptedData[1]).toHaveProperty('email__source') - expect(encryptedData[1]).toHaveProperty('email__hmac') - expect(encryptedData[1]).toHaveProperty('firstName__source') - expect(encryptedData[1]).toHaveProperty('firstName__hmac') - expect(encryptedData[1]).toHaveProperty('lastName__source') - expect(encryptedData[1]).toHaveProperty('lastName__hmac') - expect(encryptedData[1]).toHaveProperty('phoneNumber__source') - }) - - it('should handle decryption of encrypted data', async () => { - const originalData = { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test.user@example.com', - firstName: 'John', - lastName: 'Smith', - phoneNumber: '555-555-5555', - example: { - protected: 'hello world', - notProtected: 'I am not protected', - deep: { - protected: 'deep protected', - notProtected: 'deep not protected', - }, - }, - } - - // First encrypt - const encryptResult = await protectDynamo - .encryptModel(originalData, schema) - .audit({ - metadata: { sub: 'cj@cjb.io', type: 'dynamo_encrypt_model' }, - }) - - if (encryptResult.failure) { - throw new Error(`Encryption failed: ${encryptResult.failure.message}`) - } - - // Then decrypt - const decryptResult = await protectDynamo - .decryptModel(encryptResult.data, schema) - .audit({ - metadata: { sub: 'cj@cjb.io', type: 'dynamo_decrypt_model' }, - }) - if (decryptResult.failure) { - throw new Error(`Decryption failed: ${decryptResult.failure.message}`) - } - - const decryptedData = decryptResult.data - - // Verify all fields match original data - expect(decryptedData).toEqual(originalData) - }) - - it('should handle decryption of bulk encrypted data', async () => { - const originalData = [ - { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test1@example.com', - firstName: 'John', - lastName: 'Smith', - phoneNumber: '555-555-5555', - }, - { - id: '02ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test2@example.com', - firstName: 'Jane', - lastName: 'Doe', - phoneNumber: '555-555-5556', - example: { - protected: 'hello world', - notProtected: 'I am not protected', - deep: { - protected: 'deep protected', - notProtected: 'deep not protected', - }, - }, - }, - ] - - // First encrypt - const encryptResult = await protectDynamo - .bulkEncryptModels(originalData, schema) - .audit({ - metadata: { sub: 'cj@cjb.io', type: 'dynamo_bulk_encrypt_models' }, - }) - if (encryptResult.failure) { - throw new Error( - `Bulk encryption failed: ${encryptResult.failure.message}`, - ) - } - - // Then decrypt - const decryptResult = await protectDynamo - .bulkDecryptModels(encryptResult.data, schema) - .audit({ - metadata: { sub: 'cj@cjb.io', type: 'dynamo_bulk_decrypt_models' }, - }) - if (decryptResult.failure) { - throw new Error( - `Bulk decryption failed: ${decryptResult.failure.message}`, - ) - } - - const decryptedData = decryptResult.data - - // Verify all items match original data - expect(decryptedData).toEqual(originalData) - }) -}) diff --git a/packages/protect-dynamodb/__tests__/dynamodb.test.ts b/packages/protect-dynamodb/__tests__/dynamodb.test.ts deleted file mode 100644 index 0b8ee27af..000000000 --- a/packages/protect-dynamodb/__tests__/dynamodb.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable, csValue, protect } from '@cipherstash/protect' -import { beforeAll, describe, expect, it } from 'vitest' -import { protectDynamoDB } from '../src' - -const schema = csTable('dynamo_cipherstash_test', { - email: csColumn('email').equality(), - firstName: csColumn('firstName').equality(), - lastName: csColumn('lastName').equality(), - phoneNumber: csColumn('phoneNumber'), - json: csColumn('json').dataType('json'), - jsonSearchable: csColumn('jsonSearchable').dataType('json'), - //.searchableJson('users/jsonSearchable'), - example: { - protected: csValue('example.protected'), - deep: { - protected: csValue('example.deep.protected'), - protectNestedJson: csValue('example.deep.protectNestedJson').dataType( - 'json', - ), - }, - }, -}) - -describe('protect dynamodb helpers', () => { - let protectClient: Awaited> - let protectDynamo: ReturnType - - beforeAll(async () => { - protectClient = await protect({ - schemas: [schema], - }) - - protectDynamo = protectDynamoDB({ - protectClient, - }) - }) - - it('should encrypt and decrypt a model', async () => { - const testData = { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test.user@example.com', - address: '123 Main Street', - createdAt: '2024-08-15T22:14:49.948Z', - firstName: 'John', - lastName: 'Smith', - phoneNumber: '555-555-5555', - json: { - name: 'John Doe', - age: 30, - preferences: { - theme: 'dark', - notifications: true, - }, - }, - jsonSearchable: { - name: 'John Doe', - age: 30, - preferences: { - theme: 'dark', - notifications: true, - }, - }, - companyName: 'Acme Corp', - batteryBrands: ['Brand1', 'Brand2'], - metadata: { role: 'admin' }, - example: { - protected: 'hello world', - notProtected: 'I am not protected', - deep: { - protected: 'deep protected', - notProtected: 'deep not protected', - protectNestedJson: { - hello: 'world', - }, - }, - }, - } - - const result = await protectDynamo.encryptModel(testData, schema) - if (result.failure) { - throw new Error(`Encryption failed: ${result.failure.message}`) - } - - const encryptedData = result.data - - // Verify equality columns are encrypted - expect(encryptedData).toHaveProperty('email__source') - expect(encryptedData).toHaveProperty('email__hmac') - expect(encryptedData).toHaveProperty('firstName__source') - expect(encryptedData).toHaveProperty('firstName__hmac') - expect(encryptedData).toHaveProperty('lastName__source') - expect(encryptedData).toHaveProperty('lastName__hmac') - expect(encryptedData).toHaveProperty('phoneNumber__source') - expect(encryptedData).not.toHaveProperty('phoneNumber__hmac') - expect(encryptedData.example).toHaveProperty('protected__source') - expect(encryptedData.example.deep).toHaveProperty('protected__source') - - // Verify other fields remain unchanged - expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX') - expect(encryptedData.address).toBe('123 Main Street') - expect(encryptedData.createdAt).toBe('2024-08-15T22:14:49.948Z') - expect(encryptedData.companyName).toBe('Acme Corp') - expect(encryptedData.batteryBrands).toEqual(['Brand1', 'Brand2']) - expect(encryptedData.example.notProtected).toBe('I am not protected') - expect(encryptedData.example.deep.notProtected).toBe('deep not protected') - expect(encryptedData.metadata).toEqual({ role: 'admin' }) - - const decryptResult = await protectDynamo.decryptModel( - encryptedData, - schema, - ) - if (decryptResult.failure) { - throw new Error(`Decryption failed: ${decryptResult.failure.message}`) - } - - expect(decryptResult.data).toEqual(testData) - }) - - it('should handle null and undefined values', async () => { - const testData = { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: null, - firstName: undefined, - lastName: 'Smith', - phoneNumber: null, - metadata: { role: null }, - example: { - protected: null, - notProtected: 'I am not protected', - deep: { - protected: undefined, - notProtected: 'deep not protected', - }, - }, - } - - const result = await protectDynamo.encryptModel(testData, schema) - if (result.failure) { - throw new Error(`Encryption failed: ${result.failure.message}`) - } - - const encryptedData = result.data - - // Verify null/undefined equality columns are handled - expect(encryptedData).toHaveProperty('lastName__source') - expect(encryptedData).toHaveProperty('lastName__hmac') - - // Verify other fields remain unchanged - expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX') - expect(encryptedData.phoneNumber).toBeNull() - expect(encryptedData.email).toBeNull() - expect(encryptedData.firstName).toBeUndefined() - expect(encryptedData.metadata).toEqual({ role: null }) - expect(encryptedData.example.protected).toBeNull() - expect(encryptedData.example.deep.protected).toBeUndefined() - expect(encryptedData.example.deep.notProtected).toBe('deep not protected') - }) - - it('should handle empty strings and special characters', async () => { - const testData = { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: '', - firstName: 'John!@#$%^&*()', - lastName: 'Smith ', - phoneNumber: '', - metadata: { role: 'admin!@#$%^&*()' }, - } - - const result = await protectDynamo.encryptModel(testData, schema) - if (result.failure) { - throw new Error(`Encryption failed: ${result.failure.message}`) - } - - const encryptedData = result.data - - // Verify equality columns are encrypted - expect(encryptedData).toHaveProperty('email__source') - expect(encryptedData).toHaveProperty('email__hmac') - expect(encryptedData).toHaveProperty('firstName__source') - expect(encryptedData).toHaveProperty('firstName__hmac') - expect(encryptedData).toHaveProperty('lastName__source') - expect(encryptedData).toHaveProperty('lastName__hmac') - expect(encryptedData).toHaveProperty('phoneNumber__source') - expect(encryptedData).not.toHaveProperty('phoneNumber__hmac') - - // Verify other fields remain unchanged - expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX') - expect(encryptedData.metadata).toEqual({ role: 'admin!@#$%^&*()' }) - }) - - it('should handle bulk encryption', async () => { - const testData = [ - { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test1@example.com', - firstName: 'John', - lastName: 'Smith', - phoneNumber: '555-555-5555', - }, - { - id: '02ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test2@example.com', - firstName: 'Jane', - lastName: 'Doe', - phoneNumber: '555-555-5556', - }, - ] - - const result = await protectDynamo.bulkEncryptModels(testData, schema) - if (result.failure) { - throw new Error(`Bulk encryption failed: ${result.failure.message}`) - } - - const encryptedData = result.data - - // Verify both items are encrypted - expect(encryptedData).toHaveLength(2) - - // Verify first item - expect(encryptedData[0]).toHaveProperty('email__source') - expect(encryptedData[0]).toHaveProperty('email__hmac') - expect(encryptedData[0]).toHaveProperty('firstName__source') - expect(encryptedData[0]).toHaveProperty('firstName__hmac') - expect(encryptedData[0]).toHaveProperty('lastName__source') - expect(encryptedData[0]).toHaveProperty('lastName__hmac') - expect(encryptedData[0]).toHaveProperty('phoneNumber__source') - - // Verify second item - expect(encryptedData[1]).toHaveProperty('email__source') - expect(encryptedData[1]).toHaveProperty('email__hmac') - expect(encryptedData[1]).toHaveProperty('firstName__source') - expect(encryptedData[1]).toHaveProperty('firstName__hmac') - expect(encryptedData[1]).toHaveProperty('lastName__source') - expect(encryptedData[1]).toHaveProperty('lastName__hmac') - expect(encryptedData[1]).toHaveProperty('phoneNumber__source') - }) - - it('should handle decryption of encrypted data', async () => { - const originalData = { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test.user@example.com', - firstName: 'John', - lastName: 'Smith', - phoneNumber: '555-555-5555', - example: { - protected: 'hello world', - notProtected: 'I am not protected', - deep: { - protected: 'deep protected', - notProtected: 'deep not protected', - }, - }, - } - - // First encrypt - const encryptResult = await protectDynamo.encryptModel(originalData, schema) - - if (encryptResult.failure) { - throw new Error(`Encryption failed: ${encryptResult.failure.message}`) - } - - // Then decrypt - const decryptResult = await protectDynamo.decryptModel( - encryptResult.data, - schema, - ) - if (decryptResult.failure) { - throw new Error(`Decryption failed: ${decryptResult.failure.message}`) - } - - const decryptedData = decryptResult.data - - // Verify all fields match original data - expect(decryptedData).toEqual(originalData) - }) - - it('should handle decryption of bulk encrypted data', async () => { - const originalData = [ - { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test1@example.com', - firstName: 'John', - lastName: 'Smith', - phoneNumber: '555-555-5555', - }, - { - id: '02ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test2@example.com', - firstName: 'Jane', - lastName: 'Doe', - phoneNumber: '555-555-5556', - example: { - protected: 'hello world', - notProtected: 'I am not protected', - deep: { - protected: 'deep protected', - notProtected: 'deep not protected', - }, - }, - }, - ] - - // First encrypt - const encryptResult = await protectDynamo.bulkEncryptModels( - originalData, - schema, - ) - if (encryptResult.failure) { - throw new Error( - `Bulk encryption failed: ${encryptResult.failure.message}`, - ) - } - - // Then decrypt - const decryptResult = await protectDynamo.bulkDecryptModels( - encryptResult.data, - schema, - ) - if (decryptResult.failure) { - throw new Error( - `Bulk decryption failed: ${decryptResult.failure.message}`, - ) - } - - const decryptedData = decryptResult.data - - // Verify all items match original data - expect(decryptedData).toEqual(originalData) - }) -}) diff --git a/packages/protect-dynamodb/__tests__/error-codes.test.ts b/packages/protect-dynamodb/__tests__/error-codes.test.ts deleted file mode 100644 index 918331c95..000000000 --- a/packages/protect-dynamodb/__tests__/error-codes.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import 'dotenv/config' -import type { ProtectClient } from '@cipherstash/protect' -import { - csColumn, - csTable, - FfiProtectError, - protect, -} from '@cipherstash/protect' -import { beforeAll, describe, expect, it } from 'vitest' -import { protectDynamoDB } from '../src' -import type { ProtectDynamoDBError } from '../src/types' - -const FFI_TEST_TIMEOUT = 30_000 - -describe('ProtectDynamoDB Error Code Preservation', () => { - let protectClient: ProtectClient - let protectDynamo: ReturnType - - const testSchema = csTable('test_table', { - email: csColumn('email').equality(), - }) - - const badSchema = csTable('test_table', { - nonexistent: csColumn('nonexistent_column'), - }) - - beforeAll(async () => { - protectClient = await protect({ schemas: [testSchema] }) - protectDynamo = protectDynamoDB({ protectClient }) - }) - - describe('handleError FFI error code extraction', () => { - it('FfiProtectError has code property accessible', () => { - const ffiError = new FfiProtectError({ - code: 'UNKNOWN_COLUMN', - message: 'Test error', - }) - expect(ffiError.code).toBe('UNKNOWN_COLUMN') - expect(ffiError instanceof FfiProtectError).toBe(true) - }) - }) - - describe('encryptModel error codes', () => { - it( - 'preserves FFI error codes', - async () => { - const model = { nonexistent: 'test value' } - - const result = await protectDynamo.encryptModel(model, badSchema) - - expect(result.failure).toBeDefined() - expect((result.failure as ProtectDynamoDBError).code).toBe( - 'UNKNOWN_COLUMN', - ) - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('decryptModel error codes', () => { - it( - 'uses PROTECT_DYNAMODB_ERROR for IO/parsing errors without FFI codes', - async () => { - // Malformed ciphertext causes IO/parsing errors that don't have FFI error codes - const malformedItem = { - email__source: 'invalid_ciphertext_data', - } - - const result = await protectDynamo.decryptModel( - malformedItem, - testSchema, - ) - - expect(result.failure).toBeDefined() - // FFI returns undefined code for IO/parsing errors, so we fall back to generic code - expect((result.failure as ProtectDynamoDBError).code).toBe( - 'PROTECT_DYNAMODB_ERROR', - ) - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('bulkEncryptModels error codes', () => { - it( - 'preserves FFI error codes', - async () => { - const models = [{ nonexistent: 'value1' }, { nonexistent: 'value2' }] - - const result = await protectDynamo.bulkEncryptModels(models, badSchema) - - expect(result.failure).toBeDefined() - expect((result.failure as ProtectDynamoDBError).code).toBe( - 'UNKNOWN_COLUMN', - ) - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('bulkDecryptModels error codes', () => { - it( - 'uses PROTECT_DYNAMODB_ERROR for IO/parsing errors without FFI codes', - async () => { - // Malformed ciphertext causes IO/parsing errors that don't have FFI error codes - const malformedItems = [ - { email__source: 'invalid1' }, - { email__source: 'invalid2' }, - ] - - const result = await protectDynamo.bulkDecryptModels( - malformedItems, - testSchema, - ) - - expect(result.failure).toBeDefined() - // FFI returns undefined code for IO/parsing errors, so we fall back to generic code - expect((result.failure as ProtectDynamoDBError).code).toBe( - 'PROTECT_DYNAMODB_ERROR', - ) - }, - FFI_TEST_TIMEOUT, - ) - }) -}) diff --git a/packages/protect-dynamodb/__tests__/helpers.test.ts b/packages/protect-dynamodb/__tests__/helpers.test.ts deleted file mode 100644 index aeb22bdc6..000000000 --- a/packages/protect-dynamodb/__tests__/helpers.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { csColumn, csTable } from '@cipherstash/protect' -import { describe, expect, it } from 'vitest' -import { - ciphertextAttrSuffix, - searchTermAttrSuffix, - toItemWithEqlPayloads, -} from '../src/helpers' - -describe('toItemWithEqlPayloads', () => { - it('wraps searchable JSON columns as ste_vec (k: "sv") EQL payloads', () => { - // Regression test for `cast_as === 'json'` (previously 'jsonb'). The - // searchableJson() builder produces cast_as: 'json' + ste_vec index, and - // this branch must emit k: 'sv' so DynamoDB items round-trip through - // decrypt without losing the ste_vec discriminant. - const schema = csTable('users', { - preferences: csColumn('preferences').searchableJson(), - }) - - const stored = [{ s: 'selector', t: 'term' }] - const item = { - id: 'user-1', - [`preferences${ciphertextAttrSuffix}`]: stored, - } - - const result = toItemWithEqlPayloads(item, schema) - - expect(result).toEqual({ - id: 'user-1', - preferences: { - i: { c: 'preferences', t: 'users' }, - v: 2, - k: 'sv', - sv: stored, - }, - }) - }) - - it('wraps non-ste_vec columns as scalar ciphertext (k: "ct") EQL payloads', () => { - const schema = csTable('users', { - email: csColumn('email').equality(), - }) - - const ciphertext = 'mp_base85_ciphertext' - const item = { - id: 'user-1', - [`email${ciphertextAttrSuffix}`]: ciphertext, - [`email${searchTermAttrSuffix}`]: 'hmac-value', - } - - const result = toItemWithEqlPayloads(item, schema) - - // HMAC attribute is stripped; ciphertext is wrapped as k: 'ct'. - expect(result).toEqual({ - id: 'user-1', - email: { - i: { c: 'email', t: 'users' }, - v: 2, - k: 'ct', - c: ciphertext, - }, - }) - }) - - it('wraps non-searchable JSON columns as scalar ciphertext (k: "ct")', () => { - // A plain `dataType('json')` column has cast_as: 'json' but no ste_vec - // index — it must take the default `k: 'ct'` branch, not `k: 'sv'`. - const schema = csTable('users', { - metadata: csColumn('metadata').dataType('json'), - }) - - const ciphertext = 'mp_base85_ciphertext' - const item = { - id: 'user-1', - [`metadata${ciphertextAttrSuffix}`]: ciphertext, - } - - const result = toItemWithEqlPayloads(item, schema) - - expect(result).toEqual({ - id: 'user-1', - metadata: { - i: { c: 'metadata', t: 'users' }, - v: 2, - k: 'ct', - c: ciphertext, - }, - }) - }) -}) diff --git a/packages/protect-dynamodb/package.json b/packages/protect-dynamodb/package.json deleted file mode 100644 index 2e1d99d01..000000000 --- a/packages/protect-dynamodb/package.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "name": "@cipherstash/protect-dynamodb", - "version": "12.0.2-rc.0", - "description": "Protect.js DynamoDB Helpers", - "keywords": [ - "dynamodb", - "cipherstash", - "protect", - "encrypt", - "decrypt", - "security" - ], - "bugs": { - "url": "https://github.com/cipherstash/stack/issues" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/cipherstash/stack.git", - "directory": "packages/protect-dynamodb" - }, - "license": "MIT", - "author": "CipherStash ", - "type": "module", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" - } - }, - "scripts": { - "build": "tsup", - "dev": "tsup --watch", - "test": "vitest run", - "release": "tsup" - }, - "devDependencies": { - "@cipherstash/protect": "workspace:*", - "dotenv": "^17.4.2", - "tsup": "catalog:repo", - "tsx": "catalog:repo", - "typescript": "catalog:repo", - "vitest": "catalog:repo" - }, - "peerDependencies": { - "@cipherstash/protect": "workspace:*" - }, - "publishConfig": { - "access": "public" - }, - "dependencies": { - "@byteslice/result": "^0.2.0" - } -} diff --git a/packages/protect-dynamodb/src/helpers.ts b/packages/protect-dynamodb/src/helpers.ts deleted file mode 100644 index 505e5c54b..000000000 --- a/packages/protect-dynamodb/src/helpers.ts +++ /dev/null @@ -1,237 +0,0 @@ -import type { - Encrypted, - ProtectErrorCode, - ProtectTable, - ProtectTableColumn, -} from '@cipherstash/protect' -import { FfiProtectError } from '@cipherstash/protect' -import type { ProtectDynamoDBError } from './types' -export const ciphertextAttrSuffix = '__source' -export const searchTermAttrSuffix = '__hmac' - -export class ProtectDynamoDBErrorImpl - extends Error - implements ProtectDynamoDBError -{ - constructor( - message: string, - public code: ProtectErrorCode | 'PROTECT_DYNAMODB_ERROR', - public details?: Record, - ) { - super(message) - this.name = 'ProtectDynamoDBError' - } -} - -export function handleError( - error: unknown, - context: string, - options?: { - logger?: { - error: (message: string, error: Error) => void - } - errorHandler?: (error: ProtectDynamoDBError) => void - }, -): ProtectDynamoDBError { - // Preserve FFI error code if available, otherwise use generic DynamoDB error code - // Check for FfiProtectError instance or plain ProtectError objects with code property - const errorObj = error as Record - const errorCode = - error instanceof FfiProtectError - ? error.code - : errorObj && - typeof errorObj === 'object' && - 'code' in errorObj && - typeof errorObj.code === 'string' - ? (errorObj.code as ProtectErrorCode) - : 'PROTECT_DYNAMODB_ERROR' - - const errorMessage = - error instanceof Error - ? error.message - : errorObj && typeof errorObj.message === 'string' - ? errorObj.message - : String(error) - - const protectError = new ProtectDynamoDBErrorImpl(errorMessage, errorCode, { - context, - }) - - if (options?.errorHandler) { - options.errorHandler(protectError) - } - - if (options?.logger) { - options.logger.error(`Error in ${context}`, protectError) - } - - return protectError -} - -export function deepClone(obj: T): T { - if (obj === null || typeof obj !== 'object') { - return obj - } - - if (Array.isArray(obj)) { - return obj.map((item) => deepClone(item)) as unknown as T - } - - return Object.entries(obj as Record).reduce( - (acc, [key, value]) => ({ - // biome-ignore lint/performance/noAccumulatingSpread: TODO later - ...acc, - [key]: deepClone(value), - }), - {} as T, - ) -} - -export function toEncryptedDynamoItem( - encrypted: Record, - encryptedAttrs: string[], -): Record { - function processValue( - attrName: string, - attrValue: unknown, - isNested: boolean, - ): Record { - if (attrValue === null || attrValue === undefined) { - return { [attrName]: attrValue } - } - - // Handle encrypted payload - if ( - encryptedAttrs.includes(attrName) || - (isNested && - typeof attrValue === 'object' && - 'c' in (attrValue as object)) - ) { - const encryptPayload = attrValue as Encrypted - if (encryptPayload?.k === 'ct' && encryptPayload.c) { - const result: Record = {} - if (encryptPayload.hm) { - result[`${attrName}${searchTermAttrSuffix}`] = encryptPayload.hm - } - result[`${attrName}${ciphertextAttrSuffix}`] = encryptPayload.c - return result - } - - if (encryptPayload?.k === 'sv' && encryptPayload.sv) { - const result: Record = {} - result[`${attrName}${ciphertextAttrSuffix}`] = encryptPayload.sv - return result - } - } - - // Handle nested objects recursively - if (typeof attrValue === 'object' && !Array.isArray(attrValue)) { - const nestedResult = Object.entries( - attrValue as Record, - ).reduce( - (acc, [key, val]) => { - const processed = processValue(key, val, true) - return Object.assign({}, acc, processed) - }, - {} as Record, - ) - return { [attrName]: nestedResult } - } - - // Handle non-encrypted values - return { [attrName]: attrValue } - } - - return Object.entries(encrypted).reduce( - (putItem, [attrName, attrValue]) => { - const processed = processValue(attrName, attrValue, false) - return Object.assign({}, putItem, processed) - }, - {} as Record, - ) -} - -export function toItemWithEqlPayloads( - decrypted: Record, - encryptSchemas: ProtectTable, -): Record { - function processValue( - attrName: string, - attrValue: unknown, - isNested: boolean, - ): Record { - if (attrValue === null || attrValue === undefined) { - return { [attrName]: attrValue } - } - - // Skip HMAC fields - if (attrName.endsWith(searchTermAttrSuffix)) { - return {} - } - - const encryptConfig = encryptSchemas.build() - const encryptedAttrs = Object.keys(encryptConfig.columns) - const columnName = attrName.slice(0, -ciphertextAttrSuffix.length) - - // Handle encrypted payload - if ( - attrName.endsWith(ciphertextAttrSuffix) && - (encryptedAttrs.includes(columnName) || isNested) - ) { - // TODO: Implement the standardized typing for Encrypted Payloads through the FFI interface - const i = { c: columnName, t: encryptConfig.tableName } - const v = 2 - - // Nested values are not searchable, so we can just return the standard EQL payload. - // Worth noting, that encryptConfig.columns[columnName] will be undefined if isNested is true. - if ( - !isNested && - encryptConfig.columns[columnName].cast_as === 'json' && - encryptConfig.columns[columnName].indexes.ste_vec - ) { - return { - [columnName]: { - i, - v, - k: 'sv', - sv: attrValue, - }, - } - } - - return { - [columnName]: { - i, - v, - k: 'ct', - c: attrValue, - }, - } - } - - // Handle nested objects recursively - if (typeof attrValue === 'object' && !Array.isArray(attrValue)) { - const nestedResult = Object.entries( - attrValue as Record, - ).reduce( - (acc, [key, val]) => { - const processed = processValue(key, val, true) - return Object.assign({}, acc, processed) - }, - {} as Record, - ) - return { [attrName]: nestedResult } - } - - // Handle non-encrypted values - return { [attrName]: attrValue } - } - - return Object.entries(decrypted).reduce( - (formattedItem, [attrName, attrValue]) => { - const processed = processValue(attrName, attrValue, false) - return Object.assign({}, formattedItem, processed) - }, - {} as Record, - ) -} diff --git a/packages/protect-dynamodb/src/index.ts b/packages/protect-dynamodb/src/index.ts deleted file mode 100644 index fe18ddfa7..000000000 --- a/packages/protect-dynamodb/src/index.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { - Encrypted, - ProtectTable, - ProtectTableColumn, - SearchTerm, -} from '@cipherstash/protect' -import { BulkDecryptModelsOperation } from './operations/bulk-decrypt-models' -import { BulkEncryptModelsOperation } from './operations/bulk-encrypt-models' -import { DecryptModelOperation } from './operations/decrypt-model' -import { EncryptModelOperation } from './operations/encrypt-model' -import { SearchTermsOperation } from './operations/search-terms' -import type { ProtectDynamoDBConfig, ProtectDynamoDBInstance } from './types' - -export function protectDynamoDB( - config: ProtectDynamoDBConfig, -): ProtectDynamoDBInstance { - const { protectClient, options } = config - - return { - encryptModel>( - item: T, - protectTable: ProtectTable, - ) { - return new EncryptModelOperation( - protectClient, - item, - protectTable, - options, - ) - }, - - bulkEncryptModels>( - items: T[], - protectTable: ProtectTable, - ) { - return new BulkEncryptModelsOperation( - protectClient, - items, - protectTable, - options, - ) - }, - - decryptModel>( - item: Record, - protectTable: ProtectTable, - ) { - return new DecryptModelOperation( - protectClient, - item, - protectTable, - options, - ) - }, - - bulkDecryptModels>( - items: Record[], - protectTable: ProtectTable, - ) { - return new BulkDecryptModelsOperation( - protectClient, - items, - protectTable, - options, - ) - }, - - /** - * @deprecated Use `protectClient.encryptQuery(terms)` instead and extract the `hm` field for DynamoDB key lookups. - */ - createSearchTerms(terms: SearchTerm[]) { - return new SearchTermsOperation(protectClient, terms, options) - }, - } -} - -export * from './types' diff --git a/packages/protect-dynamodb/src/operations/base-operation.ts b/packages/protect-dynamodb/src/operations/base-operation.ts deleted file mode 100644 index e9ea0bb64..000000000 --- a/packages/protect-dynamodb/src/operations/base-operation.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { Result } from '@byteslice/result' -import type { ProtectDynamoDBError } from '../types' - -export type AuditConfig = { - metadata?: Record -} - -export type AuditData = { - metadata?: Record -} - -export type DynamoDBOperationOptions = { - logger?: { - error: (message: string, error: Error) => void - } - errorHandler?: (error: ProtectDynamoDBError) => void -} - -export abstract class DynamoDBOperation { - protected auditMetadata?: Record - protected logger?: DynamoDBOperationOptions['logger'] - protected errorHandler?: DynamoDBOperationOptions['errorHandler'] - - constructor(options?: DynamoDBOperationOptions) { - this.logger = options?.logger - this.errorHandler = options?.errorHandler - } - - /** - * Attach audit metadata to this operation. Can be chained. - */ - audit(config: AuditConfig): this { - this.auditMetadata = config.metadata - return this - } - - /** - * Get the audit metadata for this operation. - */ - protected getAuditData(): AuditData { - return { - metadata: this.auditMetadata, - } - } - - /** - * Execute the operation and return a Result - */ - abstract execute(): Promise> - - /** - * Make the operation thenable - */ - public then, TResult2 = never>( - onfulfilled?: - | (( - value: Result, - ) => TResult1 | PromiseLike) - | null, - onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, - ): Promise { - return this.execute().then(onfulfilled, onrejected) - } -} diff --git a/packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts b/packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts deleted file mode 100644 index f27e6dd4a..000000000 --- a/packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import type { - Decrypted, - Encrypted, - ProtectClient, - ProtectTable, - ProtectTableColumn, -} from '@cipherstash/protect' -import { handleError, toItemWithEqlPayloads } from '../helpers' -import type { ProtectDynamoDBError } from '../types' -import { - DynamoDBOperation, - type DynamoDBOperationOptions, -} from './base-operation' - -export class BulkDecryptModelsOperation< - T extends Record, -> extends DynamoDBOperation[]> { - private protectClient: ProtectClient - private items: Record[] - private protectTable: ProtectTable - - constructor( - protectClient: ProtectClient, - items: Record[], - protectTable: ProtectTable, - options?: DynamoDBOperationOptions, - ) { - super(options) - this.protectClient = protectClient - this.items = items - this.protectTable = protectTable - } - - public async execute(): Promise< - Result[], ProtectDynamoDBError> - > { - return await withResult( - async () => { - const itemsWithEqlPayloads = this.items.map((item) => - toItemWithEqlPayloads(item, this.protectTable), - ) - - const decryptResult = await this.protectClient - .bulkDecryptModels(itemsWithEqlPayloads as T[]) - .audit(this.getAuditData()) - - if (decryptResult.failure) { - // Create an Error object that preserves the FFI error code - // This is necessary because withResult's ensureError wraps non-Error objects - const error = new Error(decryptResult.failure.message) as Error & { - code?: string - } - error.code = decryptResult.failure.code - throw error - } - - return decryptResult.data - }, - (error) => - handleError(error, 'bulkDecryptModels', { - logger: this.logger, - errorHandler: this.errorHandler, - }), - ) - } -} diff --git a/packages/protect-dynamodb/src/operations/bulk-encrypt-models.ts b/packages/protect-dynamodb/src/operations/bulk-encrypt-models.ts deleted file mode 100644 index 855f9d142..000000000 --- a/packages/protect-dynamodb/src/operations/bulk-encrypt-models.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import type { - ProtectClient, - ProtectTable, - ProtectTableColumn, -} from '@cipherstash/protect' -import { deepClone, handleError, toEncryptedDynamoItem } from '../helpers' -import type { ProtectDynamoDBError } from '../types' -import { - DynamoDBOperation, - type DynamoDBOperationOptions, -} from './base-operation' - -export class BulkEncryptModelsOperation< - T extends Record, -> extends DynamoDBOperation { - private protectClient: ProtectClient - private items: T[] - private protectTable: ProtectTable - - constructor( - protectClient: ProtectClient, - items: T[], - protectTable: ProtectTable, - options?: DynamoDBOperationOptions, - ) { - super(options) - this.protectClient = protectClient - this.items = items - this.protectTable = protectTable - } - - public async execute(): Promise> { - return await withResult( - async () => { - const encryptResult = await this.protectClient - .bulkEncryptModels( - this.items.map((item) => deepClone(item)), - this.protectTable, - ) - .audit(this.getAuditData()) - - if (encryptResult.failure) { - // Create an Error object that preserves the FFI error code - // This is necessary because withResult's ensureError wraps non-Error objects - const error = new Error(encryptResult.failure.message) as Error & { - code?: string - } - error.code = encryptResult.failure.code - throw error - } - - const data = encryptResult.data.map((item) => deepClone(item)) - const encryptedAttrs = Object.keys(this.protectTable.build().columns) - - return data.map( - (encrypted) => toEncryptedDynamoItem(encrypted, encryptedAttrs) as T, - ) - }, - (error) => - handleError(error, 'bulkEncryptModels', { - logger: this.logger, - errorHandler: this.errorHandler, - }), - ) - } -} diff --git a/packages/protect-dynamodb/src/operations/decrypt-model.ts b/packages/protect-dynamodb/src/operations/decrypt-model.ts deleted file mode 100644 index c6980108b..000000000 --- a/packages/protect-dynamodb/src/operations/decrypt-model.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import type { - Decrypted, - Encrypted, - ProtectClient, - ProtectTable, - ProtectTableColumn, -} from '@cipherstash/protect' -import { handleError, toItemWithEqlPayloads } from '../helpers' -import type { ProtectDynamoDBError } from '../types' -import { - DynamoDBOperation, - type DynamoDBOperationOptions, -} from './base-operation' - -export class DecryptModelOperation< - T extends Record, -> extends DynamoDBOperation> { - private protectClient: ProtectClient - private item: Record - private protectTable: ProtectTable - - constructor( - protectClient: ProtectClient, - item: Record, - protectTable: ProtectTable, - options?: DynamoDBOperationOptions, - ) { - super(options) - this.protectClient = protectClient - this.item = item - this.protectTable = protectTable - } - - public async execute(): Promise, ProtectDynamoDBError>> { - return await withResult( - async () => { - const withEqlPayloads = toItemWithEqlPayloads( - this.item, - this.protectTable, - ) - - const decryptResult = await this.protectClient - .decryptModel(withEqlPayloads as T) - .audit(this.getAuditData()) - - if (decryptResult.failure) { - // Create an Error object that preserves the FFI error code - // This is necessary because withResult's ensureError wraps non-Error objects - const error = new Error(decryptResult.failure.message) as Error & { - code?: string - } - error.code = decryptResult.failure.code - throw error - } - - return decryptResult.data - }, - (error) => - handleError(error, 'decryptModel', { - logger: this.logger, - errorHandler: this.errorHandler, - }), - ) - } -} diff --git a/packages/protect-dynamodb/src/operations/encrypt-model.ts b/packages/protect-dynamodb/src/operations/encrypt-model.ts deleted file mode 100644 index 82ee1bd85..000000000 --- a/packages/protect-dynamodb/src/operations/encrypt-model.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import type { - ProtectClient, - ProtectTable, - ProtectTableColumn, -} from '@cipherstash/protect' -import { deepClone, handleError, toEncryptedDynamoItem } from '../helpers' -import type { ProtectDynamoDBError } from '../types' -import { - DynamoDBOperation, - type DynamoDBOperationOptions, -} from './base-operation' - -export class EncryptModelOperation< - T extends Record, -> extends DynamoDBOperation { - private protectClient: ProtectClient - private item: T - private protectTable: ProtectTable - - constructor( - protectClient: ProtectClient, - item: T, - protectTable: ProtectTable, - options?: DynamoDBOperationOptions, - ) { - super(options) - this.protectClient = protectClient - this.item = item - this.protectTable = protectTable - } - - public async execute(): Promise> { - return await withResult( - async () => { - const encryptResult = await this.protectClient - .encryptModel(deepClone(this.item), this.protectTable) - .audit(this.getAuditData()) - - if (encryptResult.failure) { - // Create an Error object that preserves the FFI error code - // This is necessary because withResult's ensureError wraps non-Error objects - const error = new Error(encryptResult.failure.message) as Error & { - code?: string - } - error.code = encryptResult.failure.code - throw error - } - - const data = deepClone(encryptResult.data) - const encryptedAttrs = Object.keys(this.protectTable.build().columns) - - return toEncryptedDynamoItem(data, encryptedAttrs) as T - }, - (error) => - handleError(error, 'encryptModel', { - logger: this.logger, - errorHandler: this.errorHandler, - }), - ) - } -} diff --git a/packages/protect-dynamodb/src/operations/search-terms.ts b/packages/protect-dynamodb/src/operations/search-terms.ts deleted file mode 100644 index 4da416bae..000000000 --- a/packages/protect-dynamodb/src/operations/search-terms.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import { - isEncryptedScalarQuery, - type ProtectClient, - type SearchTerm, -} from '@cipherstash/protect' -import { handleError } from '../helpers' -import type { ProtectDynamoDBError } from '../types' -import { - DynamoDBOperation, - type DynamoDBOperationOptions, -} from './base-operation' - -/** - * @deprecated Use `protectClient.encryptQuery(terms)` instead and extract the `hm` field for DynamoDB key lookups. - * - * @example - * ```typescript - * // Before (deprecated) - * const result = await protectDynamo.createSearchTerms([{ value, column, table }]) - * const hmac = result.data[0] - * - * // After (new API) - * const [encrypted] = await protectClient.encryptQuery([{ value, column, table, queryType: 'equality' }]) - * const hmac = encrypted.hm - * ``` - */ -export class SearchTermsOperation extends DynamoDBOperation { - private protectClient: ProtectClient - private terms: SearchTerm[] - - constructor( - protectClient: ProtectClient, - terms: SearchTerm[], - options?: DynamoDBOperationOptions, - ) { - super(options) - this.protectClient = protectClient - this.terms = terms - } - - public async execute(): Promise> { - return await withResult( - async () => { - const searchTermsResult = await this.protectClient - .createSearchTerms(this.terms) - .audit(this.getAuditData()) - - if (searchTermsResult.failure) { - throw new Error(`[protect]: ${searchTermsResult.failure.message}`) - } - - return searchTermsResult.data.map((term) => { - if (typeof term === 'string') { - throw new Error( - 'expected encrypted search term to be an EncryptedPayload', - ) - } - - // DynamoDB lookups go through equality queries → the FFI returns an - // EncryptedScalarQuery carrying `hm`. Anything else (scalar storage, - // a `bf`/`ob` query, or a SteVec payload) is a misconfiguration. - if (!isEncryptedScalarQuery(term) || !('hm' in term)) { - throw new Error('expected encrypted search term to have an HMAC') - } - - return term.hm - }) - }, - (error) => - handleError(error, 'createSearchTerms', { - logger: this.logger, - errorHandler: this.errorHandler, - }), - ) - } -} diff --git a/packages/protect-dynamodb/src/types.ts b/packages/protect-dynamodb/src/types.ts deleted file mode 100644 index 6cd7338ba..000000000 --- a/packages/protect-dynamodb/src/types.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { - Encrypted, - ProtectClient, - ProtectErrorCode, - ProtectTable, - ProtectTableColumn, - SearchTerm, -} from '@cipherstash/protect' -import type { BulkDecryptModelsOperation } from './operations/bulk-decrypt-models' -import type { BulkEncryptModelsOperation } from './operations/bulk-encrypt-models' -import type { DecryptModelOperation } from './operations/decrypt-model' -import type { EncryptModelOperation } from './operations/encrypt-model' -import type { SearchTermsOperation } from './operations/search-terms' - -export interface ProtectDynamoDBConfig { - protectClient: ProtectClient - options?: { - logger?: { - error: (message: string, error: Error) => void - } - errorHandler?: (error: ProtectDynamoDBError) => void - } -} - -export interface ProtectDynamoDBError extends Error { - code: ProtectErrorCode | 'PROTECT_DYNAMODB_ERROR' - details?: Record -} - -export interface ProtectDynamoDBInstance { - encryptModel>( - item: T, - protectTable: ProtectTable, - ): EncryptModelOperation - - bulkEncryptModels>( - items: T[], - protectTable: ProtectTable, - ): BulkEncryptModelsOperation - - decryptModel>( - item: Record, - protectTable: ProtectTable, - ): DecryptModelOperation - - bulkDecryptModels>( - items: Record[], - protectTable: ProtectTable, - ): BulkDecryptModelsOperation - - /** - * @deprecated Use `protectClient.encryptQuery(terms)` instead and extract the `hm` field for DynamoDB key lookups. - * - * @example - * ```typescript - * // Before (deprecated) - * const result = await protectDynamo.createSearchTerms([{ value, column, table }]) - * const hmac = result.data[0] - * - * // After (new API) - * const [encrypted] = await protectClient.encryptQuery([{ value, column, table, queryType: 'equality' }]) - * const hmac = encrypted.hm - * ``` - */ - createSearchTerms(terms: SearchTerm[]): SearchTermsOperation -} diff --git a/packages/protect-dynamodb/tsconfig.json b/packages/protect-dynamodb/tsconfig.json deleted file mode 100644 index 6da81c927..000000000 --- a/packages/protect-dynamodb/tsconfig.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "compilerOptions": { - // Enable latest features - "lib": ["ESNext", "DOM"], - "target": "ESNext", - "module": "ESNext", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - "esModuleInterop": true, - - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - } -} diff --git a/packages/protect-dynamodb/tsup.config.ts b/packages/protect-dynamodb/tsup.config.ts deleted file mode 100644 index 0ced0a354..000000000 --- a/packages/protect-dynamodb/tsup.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'tsup' - -export default defineConfig({ - entry: ['src/index.ts'], - format: ['cjs', 'esm'], - sourcemap: true, - dts: true, -}) diff --git a/packages/protect/.npmignore b/packages/protect/.npmignore deleted file mode 100644 index 3490e24db..000000000 --- a/packages/protect/.npmignore +++ /dev/null @@ -1,5 +0,0 @@ -.env -.turbo -node_modules -cipherstash.secret.toml -cipherstash.toml \ No newline at end of file diff --git a/packages/protect/CHANGELOG.md b/packages/protect/CHANGELOG.md deleted file mode 100644 index 9c79eaf46..000000000 --- a/packages/protect/CHANGELOG.md +++ /dev/null @@ -1,439 +0,0 @@ -# @cipherstash/protect - -## 12.0.2-rc.0 - -### Patch Changes - -- Updated dependencies [229ce59] - - @cipherstash/schema@3.0.2-rc.0 - -## 12.0.1 - -### Patch Changes - -- aa9c4b1: Documentation: refresh package READMEs after the protectjs → stack repository rename. Fixed repository and license links, replaced dead in-repo docs links with cipherstash.com/docs URLs, rewrote the incorrect @cipherstash/nextjs README, and added guidance pointing new projects to @cipherstash/stack. -- Updated dependencies [aa9c4b1] - - @cipherstash/schema@3.0.1 - -## 12.0.0 - -### Major Changes - -- f743fcc: Upgrade `@cipherstash/protect-ffi` to `0.23.0` and the bundled CipherStash EQL extension to `eql-2.3.1`. - - Breaking upstream changes adopted in this release: - - - **Encrypt-config schema version**: `buildEncryptConfig` now emits `{ v: 1, ... }` (was `{ v: 2, ... }`). protect-ffi `0.22.0` started validating this field and rejects any value other than `1` with the new `UNSUPPORTED_CONFIG_VERSION` error code. - - **Storage and query payloads are now distinct types** (protect-ffi `0.23.0`): the previously-conflated `Encrypted` type splits into `Encrypted` (storage-only, `c` required) and a new `EncryptedQuery` (search terms — scalar `unique`/`match`/`ore` lookups and `ste_vec_selector` JSON path queries; no `c`). JSON containment queries (`ste_vec_term`) still return a storage-shaped `Encrypted` payload. `encryptQuery` / `encryptQueryBulk` now return `Encrypted | EncryptedQuery`, and the stack's `EncryptedSearchTerm` / `EncryptedQueryResult` unions widen to match. `decrypt` rejects query payloads at the type level. The DynamoDB `SearchTermsOperation` narrows via `'hm' in term` rather than `term.hm`. - - **SteVec encoding default flipped**: protect-ffi's default `mode` for `ste_vec` indexes changed from `compat` to `standard`. The two encodings are not cross-compatible. Existing JSON-searchable data that was indexed under `compat` will need to be re-encrypted to be queryable. The stack adopts the new `standard` default — there is no longer a way to pin `compat` from the SDK. - - **EQL extension bumped to `eql-2.3.1`**: the new SteVec `standard` encoding requires matching support in the database EQL extension. The CLI's bundled SQL (`packages/cli/src/sql/*.sql`) and the `@cipherstash/prisma-next` install bundle (`migrations/20260601T0000_install_eql_bundle/ops.json` + `eql-install.generated.ts`) are updated to `eql-2.3.1`. Databases installed with an older EQL extension must be reinstalled (`stash db install`) before containment / contained-by queries against SteVec columns will work. `eql-2.3.1` ships the `_encrypted_check_c` fix for SteVec storage payloads ([cipherstash/encrypt-query-language#232](https://github.com/cipherstash/encrypt-query-language/issues/232)). - - **New error codes**: `ProtectErrorCode` (re-exported from `@cipherstash/protect-ffi`) gains `MATCH_REQUIRES_TEXT` and `UNSUPPORTED_CONFIG_VERSION`. Exhaustive switches over `ProtectErrorCode` will need additional cases. - - **`match` index validation**: protect-ffi now rejects `match` indexes on columns whose `cast_as` is not text-family (`'text'` / `'string'`) with `MATCH_REQUIRES_TEXT`. The stack's `freeTextSearch()` builder is unaffected because it only targets string-typed columns. - - **`Encrypted` ciphertext shape**: protect-ffi's `Encrypted` type is now a discriminated union keyed on `k` (`'ct'` for scalars, `'sv'` for SteVec). SteVec storage payloads now place the root document ciphertext at `sv[0].c`. The stack's `isEncryptedPayload` runtime check continues to work because storage payloads still carry `c` (scalar) or `sv` (SteVec). The DynamoDB helpers (`toEncryptedDynamoItem`, `SearchTermsOperation`) now narrow on `k` before reading variant-only fields. - - **Config-validation error message wording**: error messages for config-validation failures now come from upstream `ConfigError`. `ProtectError.code` values are preserved; consumers that string-match on `err.message` for config-validation errors must update. - -### Patch Changes - -- Updated dependencies [f743fcc] - - @cipherstash/schema@3.0.0 - -## 11.1.2 - -### Patch Changes - -- a8dbb65: Render every user-facing CLI string and execute every shell-out under the detected package manager (`npx` / `bunx` / `pnpm dlx` / `yarn dlx`), completing the work started in #379. Affected surfaces: `@cipherstash/cli` top-level + `auth` + `env` help, `db install` Drizzle migration steps, `db migrate` not-implemented warning, the Supabase migration SQL header, the Supabase status fallback exec, the `@cipherstash/protect` `stash` Stricli help (set/get/list/delete), the `@cipherstash/wizard` usage line and agent command allowlist, and the `@cipherstash/drizzle` `generate-eql-migration` help + drizzle-kit invocation. A new `pnpm run lint:runners` lint runs in CI and fails on any reintroduction of a hardcoded runner literal. - -## 11.1.1 - -### Patch Changes - -- afe6810: Bump protect-ffi version - -## 11.1.0 - -### Minor Changes - -- 068f820: Release the consolidated CipherStash CLI npm package. - -## 11.0.0 - -### Major Changes - -- b0e56b8: Upgrade protect-ffi to 0.21.0 and enable array_index_mode for searchable JSON - - - Upgrade `@cipherstash/protect-ffi` to 0.21.0 across all packages - - Enable `array_index_mode: 'all'` on STE vec indexes so JSON array operations - (jsonb_array_elements, jsonb_array_length, array containment) work correctly - - Delegate credential resolution entirely to protect-ffi's `withEnvCredentials` - - Download latest EQL at build/runtime instead of bundling hardcoded SQL files - -### Patch Changes - -- Updated dependencies [b0e56b8] - - @cipherstash/schema@2.2.0 - -## 10.5.0 - -### Minor Changes - -- db72e2c: Add `encryptQuery` API for encrypting query terms with explicit query type selection. - - - New `encryptQuery()` method replaces `createSearchTerms()` with improved query type handling - - Supports `equality`, `freeTextSearch`, and `orderAndRange` query types - - Deprecates `createSearchTerms()` - use `encryptQuery()` instead - - Updates drizzle operators to use correct index selection via `queryType` parameter - -- e769740: Add encrypted JSONB query support with `searchableJson()` (recommended). - - - New `searchableJson()` schema method enables encrypted JSONB path and containment queries - - Automatic query operation inference: string values become JSONPath selector queries, objects/arrays become containment queries - - Also supports explicit `queryType: 'steVecSelector'` and `queryType: 'steVecTerm'` for advanced use cases - - JSONB path utilities (`toJsonPath`, `buildNestedObject`, `parseJsonbPath`) for building encrypted JSON column queries - -### Patch Changes - -- Updated dependencies [e769740] - - @cipherstash/schema@2.1.0 - -## 10.4.0 - -### Minor Changes - -- 9ccaf68: Allow stash cli tool to read env files from .env.\*. - -## 10.3.0 - -### Minor Changes - -- a1fce2b: Add Stash interface and CLI tool. - -### Patch Changes - -- 622b684: Update @cipherstash/protect-ffi to 0.19.0 - -## 10.2.1 - -### Patch Changes - -- Updated dependencies [532ac3a] - - @cipherstash/schema@2.0.2 - -## 10.2.0 - -### Minor Changes - -- de029de: Add client safe exports. - -## 10.1.1 - -### Patch Changes - -- ff4421f: Expanded typedoc documentation -- Updated dependencies [ff4421f] - - @cipherstash/schema@2.0.1 - -## 10.1.0 - -### Minor Changes - -- 6b87c17: Added support for multi-tenant encryption with configurable keysets. - -## 10.0.2 - -### Patch Changes - -- Updated dependencies [9005484] - - @cipherstash/schema@2.0.0 - -## 10.0.1 - -### Patch Changes - -- Updated dependencies [d8ed4d4] - - @cipherstash/schema@1.1.0 - -## 10.0.0 - -### Major Changes - -- 788dbfc: Added JSON and INT data type support and update FFI to v0.17.1 with x86_64 musl environment platform support. - - - Update @cipherstash/protect-ffi from 0.16.0 to 0.17.1 with support for x86_64 musl platforms. - - Add searchableJson() method to schema for JSON field indexing (the search operations still don't work but this interface exists) - - Refactor type system: EncryptedPayload → Encrypted, add JsPlaintext - - Add comprehensive test suites for JSON, integer, and basic encryption - - Update encryption format to use 'k' property for searchable JSON - - Remove deprecated search terms tests for JSON fields - - Simplify schema data types to text, int, json only - - Update model helpers to handle new encryption format - - Fix type safety issues in bulk operations and model encryption - -### Patch Changes - -- Updated dependencies [788dbfc] - - @cipherstash/schema@1.0.0 - -## 9.6.0 - -### Minor Changes - -- c7ed7ab: Support TypeORM example with ES2022. -- 211e979: Added support for ES2022 and later. - -## 9.5.0 - -### Minor Changes - -- 6f45b02: Fully implemented audit metadata functionality. - -## 9.4.1 - -### Patch Changes - -- Updated dependencies [d0b02ea] - - @cipherstash/schema@0.1.0 - -## 9.4.0 - -### Minor Changes - -- 1cc4772: Released support for bulk encryption and decryption. - -## 9.3.0 - -### Minor Changes - -- 01fed9e: Added audit support for all protect and protect-dynamodb interfaces. - -## 9.2.0 - -### Minor Changes - -- 587f222: Added support for deeply nested protect schemas to support more complex model objects. - -## 9.1.0 - -### Minor Changes - -- c8468ee: Released initial version of the DynamoDB helper interface. - -## 9.0.0 - -### Major Changes - -- 1bc55a0: Implemented a more configurable pattern for the Protect client. - - This release introduces a new `ProtectClientConfig` type that can be used to configure the Protect client. - This is useful if you want to configure the Protect client specific to your application, and will future proof any additional configuration options that are added in the future. - - ```ts - import { protect, type ProtectClientConfig } from "@cipherstash/protect"; - - const config: ProtectClientConfig = { - schemas: [users, orders], - workspaceCrn: "your-workspace-crn", - accessKey: "your-access-key", - clientId: "your-client-id", - clientKey: "your-client-key", - }; - - const protectClient = await protect(config); - ``` - - The now deprecated method of passing your tables to the `protect` client is no longer supported. - - ```ts - import { protect, type ProtectClientConfig } from "@cipherstash/protect"; - - // old method (no longer supported) - const protectClient = await protect(users, orders); - - // required method - const config: ProtectClientConfig = { - schemas: [users, orders], - }; - - const protectClient = await protect(config); - ``` - -## 8.4.0 - -### Minor Changes - -- a471821: Fixed a bug in the model interface to correctly handle undefined and null values. - -## 8.3.0 - -### Minor Changes - -- 628acdc: Implemented createSearchTerms for a streamlined way of working with encrypted search terms. - -## 8.2.0 - -### Minor Changes - -- 0883e16: Fix cipherstash.toml and cipherstash.secret.toml file loading by bumping to @cipherstash/protect-ffi v0.14.2 - -## 8.1.0 - -### Minor Changes - -- 95c891d: Implemented CipherStash CRN in favor of workspace ID. - - - Replaces the environment variable `CS_WORKSPACE_ID` with `CS_WORKSPACE_CRN` - - Replaces `workspace_id` with `workspace_crn` in the `cipherstash.toml` file - -- 18d3653: Fixed handling composite types for EQL v2. - -## 8.0.0 - -### Major Changes - -- 8a4ea80: Implement EQL v2 data structure. - - - Support for Protect.js searchable encryption when using Supabase. - - Encrypted payloads are now composite types which support searchable encryption with EQL v2 functions. - - The `data` property is an object that matches the EQL v2 data structure. - -## 7.0.0 - -### Major Changes - -- 2cb2d84: Replaced bulk operations with model operations. - -## 6.3.0 - -### Minor Changes - -- a564f21: Bumped versions of dependencies to address CWE-346. - -## 6.2.0 - -### Minor Changes - -- fe4b443: Added symbolic link for protect readme. - -## 6.1.0 - -### Minor Changes - -- 43e1acb: \* Added support for searching encrypted data - - Added a schema strategy for defining your schema - - Required schema to initialize the protect client - -## 6.0.0 - -### Major Changes - -- f4d8334: Released protectjs-ffi with toml file configuration support. - Added a `withResult` pattern to all public facing functions for better error handling. - Updated all documentation to reflect the new configuration pattern. - -## 5.2.0 - -### Minor Changes - -- 499c246: Implemented protectjs-ffi. - -## 5.1.0 - -### Minor Changes - -- 5a34e76: Rebranded logging context and fixed tests. - -## 5.0.0 - -### Major Changes - -- 76599e5: Rebrand jseql to protect. - -## 4.0.0 - -### Major Changes - -- 5c08fe5: Enforced lock context to be called as a proto function rather than an optional argument for crypto functions. - There was a bug that caused the lock context to be interpreted as undefined when the users intention was to use it causing the encryption/decryption to fail. - This is a breaking change for users who were using the lock context as an optional argument. - To use the lock context, call the `withLockContext` method on the encrypt, decrypt, and bulk encrypt/decrypt functions, passing the lock context as a parameter rather than as an optional argument. - -## 3.9.0 - -### Minor Changes - -- e885975: Fixed improper use of throwing errors, and log with jseql logger. - -## 3.8.0 - -### Minor Changes - -- eeaec18: Implemented typing and import synatx for es6. - -## 3.7.0 - -### Minor Changes - -- 7b8ec52: Implement packageless logging framework. - -## 3.6.0 - -### Minor Changes - -- 7480cfd: Fixed node:util package bundling. - -## 3.5.0 - -### Minor Changes - -- c0123be: Replaced logtape with native node debuglog. - -## 3.4.0 - -### Minor Changes - -- 9a3132c: Implemented bulk encryption and decryptions. -- 9a3132c: Fixed the logtape peer dependency version. - -## 3.3.0 - -### Minor Changes - -- 80ee5af: Fixed bugs when implmenting the lock context with CTS v2 tokens. - -## 3.2.0 - -### Minor Changes - -- 0526f60: Use the latest jseql-ffi (0.4.0) -- fbb2bcb: Implemented CTS v2 for identity lock. - -## 3.1.0 - -### Minor Changes - -- 71ce612: Released support for LockContext initializer. -- e484718: Refactored init function to not require envrionment variables as arguments. -- e484718: Replaces jset with vitest for better typescript support. - -## 3.0.0 - -### Major Changes - -- 2eefb5f: Implemented jseql-ffi for inline crypto. - -## 2.1.0 - -### Minor Changes - -- 0536f03: Implemented new CsPlaintextV1Schema type and schema. - -## 2.0.0 - -### Major Changes - -- bea60c4: Added release management. - -## 1.0.0 - -### Major Changes - -- Released the initial version of jseql. diff --git a/packages/protect/README.md b/packages/protect/README.md deleted file mode 100644 index 06e45b53e..000000000 --- a/packages/protect/README.md +++ /dev/null @@ -1,1102 +0,0 @@ -

- CipherStash Logo -
- Protect.js -

-

- End-to-end field level encryption for JavaScript/TypeScript apps with zero‑knowledge key management. Search encrypted data without decrypting it. -
-

-

-
⭐ Please star this repo if you find it useful!
-
- - - -> [!TIP] -> `@cipherstash/protect` is the core encryption library that powers [`@cipherstash/stack`](https://www.npmjs.com/package/@cipherstash/stack). For new projects we recommend `@cipherstash/stack`, which re-exports this functionality alongside integrations for Drizzle, Supabase, DynamoDB, secrets, and identity-aware encryption. See the [CipherStash docs](https://cipherstash.com/docs) for the current API. - -Protect.js lets you encrypt every value with its own key—without sacrificing performance or usability. Encryption happens in your app; ciphertext is stored in your database. - -Per‑value unique keys are powered by CipherStash [ZeroKMS](https://cipherstash.com/products/zerokms) bulk key operations, backed by a root key in [AWS KMS](https://docs.aws.amazon.com/kms/latest/developerguide/overview.html). - -Encrypted data is structured as an [EQL](https://github.com/cipherstash/encrypt-query-language) JSON payload and can be stored in any database that supports JSONB. - -> [!IMPORTANT] -> Searching, sorting, and filtering on encrypted data is currently only supported when storing encrypted data in PostgreSQL. -> Read more about [searching encrypted data](https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption). - -Looking for DynamoDB support? Check out the [Protect.js for DynamoDB helper library](https://www.npmjs.com/package/@cipherstash/protect-dynamodb). - -## Quick start (60 seconds) - -Create an account and workspace in the [CipherStash dashboard](https://cipherstash.com/signup), then follow the onboarding guide to generate your client credentials and store them in your `.env` file. - -Install the package: - -```bash -npm install @cipherstash/protect -``` - -Start encrypting data: - -```ts -import { protect } from "@cipherstash/protect"; -import { csTable, csColumn } from "@cipherstash/protect"; - -// 1) Define a schema -const users = csTable("users", { email: csColumn("email") }); - -// 2) Create a client (requires CS_* env vars) -const client = await protect({ schemas: [users] }); - -// 3) Encrypt → store JSONB payload -const encrypted = await client.encrypt("alice@example.com", { - table: users, - column: users.email, -}); - -if (encrypted.failure) { - // You decide how to handle the failure and the user experience -} - -// 4) Decrypt later -const decrypted = await client.decrypt(encrypted.data); -``` - -## Architecture (high level) - -![Protect.js Architecture Diagram](https://raw.githubusercontent.com/cipherstash/stack/45e40dc888ddc412c361139192ed24668d3db60d/docs/images/protectjs-architecture.png) - -## Table of contents - -- [Quick start (60 seconds)](#quick-start-60-seconds) -- [Architecture (high level)](#architecture-high-level) -- [Features](#features) -- [Installing Protect.js](#installing-protectjs) -- [Getting started](#getting-started) -- [Identity-aware encryption](#identity-aware-encryption) -- [Supported data types](#supported-data-types) -- [Searchable encryption](#searchable-encryption) -- [Multi-tenant encryption](#multi-tenant-encryption) -- [Logging](#logging) -- [CipherStash Client](#cipherstash-client) -- [Example applications](#example-applications) -- [Builds and bundling](#builds-and-bundling) -- [Contributing](#contributing) -- [License](#license) - -For more specific documentation, refer to the [docs](https://cipherstash.com/docs). - -## Features - -Protect.js protects data in using industry-standard AES encryption. -Protect.js uses [ZeroKMS](https://cipherstash.com/products/zerokms) for bulk encryption and decryption operations. -This enables every encrypted value, in every column, in every row in your database to have a unique key — without sacrificing performance. - -**Features:** - -- **Bulk encryption and decryption**: Protect.js uses [ZeroKMS](https://cipherstash.com/products/zerokms) for encrypting and decrypting thousands of records at once, while using a unique key for every value. -- **Single item encryption and decryption**: Just looking for a way to encrypt and decrypt single values? Protect.js has you covered. -- **Really fast:** ZeroKMS's performance makes using millions of unique keys feasible and performant for real-world applications built with Protect.js. -- **Identity-aware encryption**: Lock down access to sensitive data by requiring a valid JWT to perform a decryption. -- **Audit trail**: Every decryption event will be logged in ZeroKMS to help you prove compliance. -- **Searchable encryption**: Protect.js supports searching encrypted data in PostgreSQL. -- **TypeScript support**: Strongly typed with TypeScript interfaces and types. - -**Use cases:** - -- **Trusted data access**: make sure only your end-users can access their sensitive data stored in your product. -- **Meet compliance requirements faster:** meet and exceed the data encryption requirements of SOC2 and ISO27001. -- **Reduce the blast radius of data breaches:** limit the impact of exploited vulnerabilities to only the data your end-users can decrypt. - -## Installing Protect.js - -Install the [`@cipherstash/protect` package](https://www.npmjs.com/package/@cipherstash/protect) with your package manager of choice: - -```bash -npm install @cipherstash/protect -# or -yarn add @cipherstash/protect -# or -pnpm add @cipherstash/protect -``` - -> [!TIP] -> [Bun](https://bun.sh/) is not currently supported due to a lack of [Node-API compatibility](https://github.com/oven-sh/bun/issues/158). Under the hood, Protect.js uses [CipherStash Client](#cipherstash-client) which is written in Rust and embedded using [Neon](https://github.com/neon-bindings/neon). - -### Opt-out of bundling - -> [!IMPORTANT] -> **You need to opt-out of bundling when using Protect.js.** - -Protect.js uses Node.js specific features and requires the use of the [native Node.js `require`](https://nodejs.org/api/modules.html#requireid). - -When using Protect.js, you need to opt-out of bundling for tools like [Webpack](https://webpack.js.org/configuration/externals/), [esbuild](https://webpack.js.org/configuration/externals/), or [Next.js](https://nextjs.org/docs/app/api-reference/config/next-config-js/serverExternalPackages). - -Read more about [building and bundling with Protect.js](#builds-and-bundling). - -## Getting started - -- 🆕 **Existing app?** Skip to [the next step](#configuration). -- 🌱 **Clean slate?** Check out the [getting started tutorial](https://cipherstash.com/docs/stack/quickstart). - -### Configuration - -If you haven't already, sign up for a [CipherStash account](https://cipherstash.com/signup). -Once you have an account, you will create a Workspace which is scoped to your application environment. - -Follow the onboarding steps to get your first set of credentials required to use Protect.js. -By the end of the onboarding, you will have the following environment variables: - -```bash -CS_WORKSPACE_CRN= # The workspace identifier -CS_CLIENT_ID= # The client identifier -CS_CLIENT_KEY= # The client key which is used as key material in combination with ZeroKMS -CS_CLIENT_ACCESS_KEY= # The API key used for authenticating with the CipherStash API -``` - -Save these environment variables to a `.env` file in your project. - -### Basic file structure - -The following is the basic file structure of the project. -In the `src/protect/` directory, we have the table definition in `schema.ts` and the protect client in `index.ts`. - -``` -📦 - ├ 📂 src - │ ├ 📂 protect - │ │ ├ 📜 index.ts - │ │ └ 📜 schema.ts - │ └ 📜 index.ts - ├ 📜 .env - ├ 📜 cipherstash.toml - ├ 📜 cipherstash.secret.toml - ├ 📜 package.json - └ 📜 tsconfig.json -``` - -### Define your schema - -Protect.js uses a schema to define the tables and columns that you want to encrypt and decrypt. - -Define your tables and columns by adding this to `src/protect/schema.ts`: - -```ts -import { csTable, csColumn } from "@cipherstash/protect"; - -export const users = csTable("users", { - email: csColumn("email"), -}); - -export const orders = csTable("orders", { - address: csColumn("address"), -}); -``` - -**Searchable encryption:** - -If you want to search encrypted data in your PostgreSQL database, you must declare the indexes in schema in `src/protect/schema.ts`: - -```ts -import { csTable, csColumn } from "@cipherstash/protect"; - -export const users = csTable("users", { - email: csColumn("email").freeTextSearch().equality().orderAndRange(), -}); - -export const orders = csTable("orders", { - address: csColumn("address"), -}); - -export const documents = csTable("documents", { - metadata: csColumn("metadata").searchableJson(), // Enables encrypted JSONB queries -}); -``` - -Read more about [defining your schema](https://cipherstash.com/docs/stack/cipherstash/encryption/schema). - -### Initialize the Protect client - -To import the `protect` function and initialize a client with your defined schema, add the following to `src/protect/index.ts`: - -```ts -import { protect, type ProtectClientConfig } from "@cipherstash/protect"; -import { users, orders } from "./schema"; - -const config: ProtectClientConfig = { - schemas: [users, orders], -} - -// Pass all your tables to the protect function to initialize the client -export const protectClient = await protect(config); -``` - -The `protect` function requires at least one `csTable` be provided in the `schemas` array. - -### Encrypt data - -Protect.js provides the `encrypt` function on `protectClient` to encrypt data. -`encrypt` takes a plaintext string, and an object with the table and column as parameters. - -To start encrypting data, add the following to `src/index.ts`: - -```typescript -import { users } from "./protect/schema"; -import { protectClient } from "./protect"; - -const encryptResult = await protectClient.encrypt("secret@squirrel.example", { - column: users.email, - table: users, -}); - -if (encryptResult.failure) { - // Handle the failure - console.log( - "error when encrypting:", - encryptResult.failure.type, - encryptResult.failure.message - ); -} - -console.log("EQL Payload containing ciphertexts:", encryptResult.data); -``` - -The `encrypt` function will return a `Result` object with either a `data` key, or a `failure` key. -The `encryptResult` will return one of the following: - -```typescript -// Success -{ - data: EncryptedPayload -} - -// Failure -{ - failure: { - type: 'EncryptionError', - message: 'A message about the error' - } -} -``` - -### Decrypt data - -Protect.js provides the `decrypt` function on `protectClient` to decrypt data. -`decrypt` takes an encrypted data object as a parameter. - -To start decrypting data, add the following to `src/index.ts`: - -```typescript -import { protectClient } from "./protect"; - -// encryptResult is the EQL payload from the previous step -const decryptResult = await protectClient.decrypt(encryptResult.data); - -if (decryptResult.failure) { - // Handle the failure - console.log( - "error when decrypting:", - decryptResult.failure.type, - decryptResult.failure.message - ); -} - -const plaintext = decryptResult.data; -console.log("plaintext:", plaintext); -``` - -The `decrypt` function returns a `Result` object with either a `data` key, or a `failure` key. -The `decryptResult` will return one of the following: - -```typescript -// Success -{ - data: 'secret@squirrel.example' -} - -// Failure -{ - failure: { - type: 'DecryptionError', - message: 'A message about the error' - } -} -``` - -### Working with models and objects - -Protect.js provides model-level encryption methods that make it easy to encrypt and decrypt entire objects. -These methods automatically handle the encryption of fields defined in your schema. - -If you are working with a large data set, the model operations are significantly faster than encrypting and decrypting individual objects as they are able to perform bulk operations. - -> [!TIP] -> CipherStash [ZeroKMS](https://cipherstash.com/products/zerokms) is optimized for bulk operations. -> -> All the model operations are able to take advantage of this performance for real-world use cases by only making a single call to ZeroKMS regardless of the number of objects you are encrypting or decrypting while still using a unique key for each record. - -#### Encrypting a model - -Use the `encryptModel` method to encrypt a model's fields that are defined in your schema: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -// Your model with plaintext values -const user = { - id: "1", - email: "user@example.com", - address: "123 Main St", - createdAt: new Date("2024-01-01"), -}; - -const encryptedResult = await protectClient.encryptModel(user, users); - -if (encryptedResult.failure) { - // Handle the failure - console.log( - "error when encrypting:", - encryptedResult.failure.type, - encryptedResult.failure.message - ); -} - -const encryptedUser = encryptedResult.data; -console.log("encrypted user:", encryptedUser); -``` - -The `encryptModel` function will only encrypt fields that are defined in your schema. -Other fields (like `id` and `createdAt` in the example above) will remain unchanged. - -#### Type safety with models - -Protect.js provides strong TypeScript support for model operations. -You can specify your model's type to ensure end-to-end type safety: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -// Define your model type -type User = { - id: string; - email: string | null; - address: string | null; - createdAt: Date; - updatedAt: Date; - metadata?: { - preferences?: { - notifications: boolean; - theme: string; - }; - }; -}; - -// The encryptModel method will ensure type safety -const encryptedResult = await protectClient.encryptModel(user, users); - -if (encryptedResult.failure) { - // Handle the failure -} - -const encryptedUser = encryptedResult.data; -// TypeScript knows that encryptedUser matches the User type structure -// but with encrypted fields for those defined in the schema - -// Decryption maintains type safety -const decryptedResult = await protectClient.decryptModel(encryptedUser); - -if (decryptedResult.failure) { - // Handle the failure -} - -const decryptedUser = decryptedResult.data; -// decryptedUser is fully typed as User - -// Bulk operations also support type safety -const bulkEncryptedResult = await protectClient.bulkEncryptModels( - userModels, - users -); - -const bulkDecryptedResult = await protectClient.bulkDecryptModels( - bulkEncryptedResult.data -); -``` - -The type system ensures that: - -- Input models match your defined type structure -- Only fields defined in your schema are encrypted -- Encrypted and decrypted results maintain the correct type structure -- Optional and nullable fields are properly handled -- Nested object structures are preserved -- Additional properties not defined in the schema remain unchanged - -This type safety helps catch potential issues at compile time and provides better IDE support with autocompletion and type hints. - -> [!TIP] -> When using TypeScript with an ORM, you can reuse your ORM's model types directly with Protect.js's model operations. - -Example with Drizzle infered types: - -```typescript -import { protectClient } from "./protect"; -import { jsonb, pgTable, serial, InferSelectModel } from "drizzle-orm/pg-core"; -import { csTable, csColumn } from "@cipherstash/protect"; - -const protectUsers = csTable("users", { - email: csColumn("email"), -}); - -const users = pgTable("users", { - id: serial("id").primaryKey(), - email: jsonb("email").notNull(), -}); - -type User = InferSelectModel; - -const user = { - id: "1", - email: "user@example.com", -}; - -// Drizzle User type works directly with model operations -const encryptedResult = await protectClient.encryptModel( - user, - protectUsers -); -``` - -#### Decrypting a model - -Use the `decryptModel` method to decrypt a model's encrypted fields: - -```typescript -import { protectClient } from "./protect"; - -const decryptedResult = await protectClient.decryptModel(encryptedUser); - -if (decryptedResult.failure) { - // Handle the failure - console.log( - "error when decrypting:", - decryptedResult.failure.type, - decryptedResult.failure.message - ); -} - -const decryptedUser = decryptedResult.data; -console.log("decrypted user:", decryptedUser); -``` - -#### Bulk model operations - -For better performance when working with multiple models, use the `bulkEncryptModels` and `bulkDecryptModels` methods: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -// Array of models with plaintext values -const userModels = [ - { - id: "1", - email: "user1@example.com", - address: "123 Main St", - }, - { - id: "2", - email: "user2@example.com", - address: "456 Oak Ave", - }, -]; - -// Encrypt multiple models at once -const encryptedResult = await protectClient.bulkEncryptModels( - userModels, - users -); - -if (encryptedResult.failure) { - // Handle the failure -} - -const encryptedUsers = encryptedResult.data; - -// Decrypt multiple models at once -const decryptedResult = await protectClient.bulkDecryptModels(encryptedUsers); - -if (decryptedResult.failure) { - // Handle the failure -} - -const decryptedUsers = decryptedResult.data; -``` - -The model encryption methods provide a higher-level interface that's particularly useful when working with ORMs or when you need to encrypt multiple fields in an object. -They automatically handle the mapping between your model's structure and the encrypted fields defined in your schema. - -### Bulk operations - -Protect.js provides direct access to ZeroKMS bulk operations through the `bulkEncrypt` and `bulkDecrypt` methods. These methods are ideal when you need maximum performance and want to handle the correlation between encrypted/decrypted values and your application data manually. - -> [!TIP] -> The bulk operations provide the most direct interface to ZeroKMS's blazing fast bulk encryption and decryption capabilities. Each value gets a unique key while maintaining optimal performance through a single call to ZeroKMS. - -#### Bulk encryption - -Use the `bulkEncrypt` method to encrypt multiple plaintext values at once: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -// Array of plaintext values with optional IDs for correlation -const plaintexts = [ - { id: "user1", plaintext: "alice@example.com" }, - { id: "user2", plaintext: "bob@example.com" }, - { id: "user3", plaintext: "charlie@example.com" }, -]; - -const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, -}); - -if (encryptedResult.failure) { - // Handle the failure - console.log( - "error when bulk encrypting:", - encryptedResult.failure.type, - encryptedResult.failure.message - ); -} - -const encryptedData = encryptedResult.data; -console.log("encrypted data:", encryptedData); -``` - -The `bulkEncrypt` method returns an array of objects with the following structure: - -```typescript -[ - { id: "user1", data: EncryptedPayload }, - { id: "user2", data: EncryptedPayload }, - { id: "user3", data: EncryptedPayload }, -] -``` - -You can also encrypt without IDs if you don't need correlation: - -```typescript -const plaintexts = [ - { plaintext: "alice@example.com" }, - { plaintext: "bob@example.com" }, - { plaintext: "charlie@example.com" }, -]; - -const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, -}); -``` - -#### Bulk decryption - -Use the `bulkDecrypt` method to decrypt multiple encrypted values at once: - -```typescript -import { protectClient } from "./protect"; - -// encryptedData is the result from bulkEncrypt -const decryptedResult = await protectClient.bulkDecrypt(encryptedData); - -if (decryptedResult.failure) { - // Handle the failure - console.log( - "error when bulk decrypting:", - decryptedResult.failure.type, - decryptedResult.failure.message - ); -} - -const decryptedData = decryptedResult.data; -console.log("decrypted data:", decryptedData); -``` - -The `bulkDecrypt` method returns an array of objects with the following structure: - -```typescript -[ - { id: "user1", data: "alice@example.com" }, - { id: "user2", data: "bob@example.com" }, - { id: "user3", data: "charlie@example.com" }, -] -``` - -#### Response structure - -The `bulkDecrypt` method returns a `Result` object that represents the overall operation status. When successful from an HTTP and execution perspective, the `data` field contains an array where each item can have one of two outcomes: - -- **Success**: The item has a `data` field containing the decrypted plaintext -- **Failure**: The item has an `error` field containing a specific error message explaining why that particular decryption failed - -```typescript -// Example response structure -{ - data: [ - { id: "user1", data: "alice@example.com" }, // Success - { id: "user2", error: "Invalid ciphertext format" }, // Failure - { id: "user3", data: "charlie@example.com" }, // Success - ] -} -``` - -> [!NOTE] -> The underlying ZeroKMS response uses HTTP status code 207 (Multi-Status) to indicate that the bulk operation completed, but individual items within the batch may have succeeded or failed. This allows you to handle partial failures gracefully while still processing the successful decryptions. - -You can handle mixed results by checking each item: - -```typescript -const decryptedResult = await protectClient.bulkDecrypt(encryptedData); - -if (decryptedResult.failure) { - // Handle overall operation failure - console.log("Bulk decryption failed:", decryptedResult.failure.message); - return; -} - -// Process individual results -decryptedResult.data.forEach((item) => { - if ('data' in item) { - // Success - item.data contains the decrypted plaintext - console.log(`Decrypted ${item.id}:`, item.data); - } else if ('error' in item) { - // Failure - item.error contains the specific error message - console.log(`Failed to decrypt ${item.id}:`, item.error); - } -}); -``` - -#### Handling null values - -Bulk operations properly handle null values in both encryption and decryption: - -```typescript -const plaintexts = [ - { id: "user1", plaintext: "alice@example.com" }, - { id: "user2", plaintext: null }, - { id: "user3", plaintext: "charlie@example.com" }, -]; - -const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, -}); - -// Null values are preserved in the encrypted result -// encryptedResult.data[1].data will be null - -const decryptedResult = await protectClient.bulkDecrypt(encryptedResult.data); - -// Null values are preserved in the decrypted result -// decryptedResult.data[1].data will be null -``` - -#### Using bulk operations with lock contexts - -Bulk operations support identity-aware encryption through lock contexts: - -```typescript -import { LockContext } from "@cipherstash/protect/identify"; - -const lc = new LockContext(); -const lockContext = await lc.identify(userJwt); - -if (lockContext.failure) { - // Handle the failure -} - -const plaintexts = [ - { id: "user1", plaintext: "alice@example.com" }, - { id: "user2", plaintext: "bob@example.com" }, -]; - -// Encrypt with lock context -const encryptedResult = await protectClient - .bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - .withLockContext(lockContext.data); - -// Decrypt with lock context -const decryptedResult = await protectClient - .bulkDecrypt(encryptedResult.data) - .withLockContext(lockContext.data); -``` - -#### Performance considerations - -Bulk operations are optimized for performance and can handle thousands of values efficiently: - -```typescript -// Create a large array of values -const plaintexts = Array.from({ length: 1000 }, (_, i) => ({ - id: `user${i}`, - plaintext: `user${i}@example.com`, -})); - -// Single call to ZeroKMS for all 1000 values -const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, -}); - -// Single call to ZeroKMS for all 1000 values -const decryptedResult = await protectClient.bulkDecrypt(encryptedResult.data); -``` - -The bulk operations maintain the same security guarantees as individual operations - each value gets a unique key - while providing optimal performance through ZeroKMS's bulk processing capabilities. - -### Store encrypted data in a database - -Encrypted data can be stored in any database that supports JSONB, noting that searchable encryption is only supported in PostgreSQL at the moment. - -To store the encrypted data, specify the column type as `jsonb`. - -```sql -CREATE TABLE users ( - id SERIAL PRIMARY KEY, - email jsonb NOT NULL, -); -``` - -#### Searchable encryption in PostgreSQL - -To enable searchable encryption in PostgreSQL, [install the EQL custom types and functions](https://github.com/cipherstash/encrypt-query-language?tab=readme-ov-file#installation). - -1. Download the EQL install script. The version is pinned to match this release of Protect.js — install exactly this version: - - ```sh - curl -sLo cipherstash-encrypt.sql https://github.com/cipherstash/encrypt-query-language/releases/download/eql-2.3.1/cipherstash-encrypt.sql - ``` - - Using [Supabase](https://supabase.com/)? We ship an EQL release specifically for Supabase. - Download the matching Supabase EQL install script: - - ```sh - curl -sLo cipherstash-encrypt-supabase.sql https://github.com/cipherstash/encrypt-query-language/releases/download/eql-2.3.1/cipherstash-encrypt-supabase.sql - ``` - -2. Run this command to install the custom types and functions: - - ```sh - psql -f cipherstash-encrypt.sql - ``` - - or with Supabase: - - ```sh - psql -f cipherstash-encrypt-supabase.sql - ``` - -EQL is now installed in your database and you can enable searchable encryption by adding the `eql_v2_encrypted` type to a column. - -```sql -CREATE TABLE users ( - id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - email eql_v2_encrypted -); -``` - -> [!WARNING] -> The `eql_v2_encrypted` type is a [composite type](https://www.postgresql.org/docs/current/rowtypes.html) and each ORM/client has a different way of handling inserts and selects. -> We've documented how to handle inserts and selects for the different ORMs/clients in the [docs](https://cipherstash.com/docs). - -Read more about [how to search encrypted data](https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption) in the docs. - -## Identity-aware encryption - -> [!IMPORTANT] -> Right now identity-aware encryption is only supported if you are using [Clerk](https://clerk.com/) as your identity provider. -> Read more about [lock contexts with Clerk and Next.js](https://cipherstash.com/docs/stack/cipherstash/encryption/identity). - -Protect.js can add an additional layer of protection to your data by requiring a valid JWT to perform a decryption. - -This ensures that only the user who encrypted data is able to decrypt it. - -Protect.js does this through a mechanism called a _lock context_. - -### Lock context - -Lock contexts ensure that only specific users can access sensitive data. - -> [!CAUTION] -> You must use the same lock context to encrypt and decrypt data. -> If you use different lock contexts, you will be unable to decrypt the data. - -To use a lock context, initialize a `LockContext` object with the identity claims. - -```typescript -import { LockContext } from "@cipherstash/protect/identify"; - -// protectClient from the previous steps -const lc = new LockContext(); -``` - -> [!NOTE] -> When initializing a `LockContext`, the default context is set to use the `sub` Identity Claim. - -### Identifying a user for a lock context - -A lock context needs to be locked to a user. -To identify the user, call the `identify` method on the lock context object, and pass a valid JWT from a user's session: - -```typescript -const identifyResult = await lc.identify(jwt); - -// The identify method returns the same Result pattern as the encrypt and decrypt methods. -if (identifyResult.failure) { - // Hanlde the failure -} - -const lockContext = identifyResult.data; -``` - -### Encrypting data with a lock context - -To encrypt data with a lock context, call the optional `withLockContext` method on the `encrypt` function and pass the lock context object as a parameter: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -const encryptResult = await protectClient - .encrypt("plaintext", { - table: users, - column: users.email, - }) - .withLockContext(lockContext); - -if (encryptResult.failure) { - // Handle the failure -} - -console.log("EQL Payload containing ciphertexts:", encryptResult.data); -``` - -### Decrypting data with a lock context - -To decrypt data with a lock context, call the optional `withLockContext` method on the `decrypt` function and pass the lock context object as a parameter: - -```typescript -import { protectClient } from "./protect"; - -const decryptResult = await protectClient - .decrypt(encryptResult.data) - .withLockContext(lockContext); - -if (decryptResult.failure) { - // Handle the failure -} - -const plaintext = decryptResult.data; -``` - -### Model encryption with lock context - -All model operations support lock contexts for identity-aware encryption: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -const myUsers = [ - { - id: "1", - email: "user@example.com", - address: "123 Main St", - createdAt: new Date("2024-01-01"), - }, - { - id: "2", - email: "user2@example.com", - address: "456 Oak Ave", - }, -]; - -// Encrypt a model with lock context -const encryptedResult = await protectClient - .encryptModel(myUsers[0], users) - .withLockContext(lockContext); - -if (encryptedResult.failure) { - // Handle the failure -} - -// Decrypt a model with lock context -const decryptedResult = await protectClient - .decryptModel(encryptedResult.data) - .withLockContext(lockContext); - -// Bulk operations also support lock contexts -const bulkEncryptedResult = await protectClient - .bulkEncryptModels(myUsers, users) - .withLockContext(lockContext); - -const bulkDecryptedResult = await protectClient - .bulkDecryptModels(bulkEncryptedResult.data) - .withLockContext(lockContext); -``` - -## Supported data types - -Protect.js currently supports encrypting and decrypting text. -Other data types like booleans, dates, ints, floats, and JSON are well-supported in other CipherStash products, and will be coming to Protect.js soon. - -Until support for other data types are available, you can express interest in this feature by adding a :+1: on this [GitHub Issue](https://github.com/cipherstash/stack/issues/48). - -## Searchable encryption - -Protect.js supports searching encrypted data in PostgreSQL: - -- **Text columns**: Use `.equality()`, `.freeTextSearch()`, and `.orderAndRange()` for exact match, text search, and sorting/range queries. -- **JSONB columns**: Use `.searchableJson()` (recommended) to enable encrypted JSONPath selector and containment queries on JSON data. The query operation is automatically inferred from the plaintext type. - -Read more about [searching encrypted data](https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption) and the [PostgreSQL implementation details](https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption) in the docs. - -## Multi-tenant encryption - -Protect.js supports multi-tenant encryption by using keysets. -Each keyset is cryptographically isolated from other keysets which esentially means that each tenant has their own unique keyspace. -If you are using a multi-tenant application, you can use keysets to encrypt data for each tenant creating a strong security boundary. - -In the [CipherStash Dashboard](https://dashboard.cipherstash.com/workspaces/_/encryption/keysets), you can create and manage keysets and then use the keyset identifier to encrypt data for each tenant when initializing the Protect.js client. - -```typescript -import { protect } from "@cipherstash/protect"; -import { users } from "./protect/schema"; - -const protectClient = await protect({ - schemas: [users], - keyset: { - // Must be a valid UUID which can be found in the CipherStash Dashboard - id: '123e4567-e89b-12d3-a456-426614174000' - }, -}) - -// or with a keyset name - -const protectClient = await protect({ - schemas: [users], - keyset: { - name: 'Company A' - }, -}) -``` - -> [!IMPORTANT] -> When creating a new keyset, make sure to grant your client access to the keyset or client initialization will fail. -> Read more about [managing keyset access](https://cipherstash.com/docs/platform/workspaces/key-sets). - -## Logging - -> [!TIP] -> `@cipherstash/protect` will NEVER log plaintext data. -> This is by design to prevent sensitive data from leaking into logs. - -`@cipherstash/protect` and `@cipherstash/nextjs` will log to the console with a log level of `info` by default. -To enable the logger, configure the following environment variable: - -```bash -PROTECT_LOG_LEVEL=debug # Enable debug logging -PROTECT_LOG_LEVEL=info # Enable info logging -PROTECT_LOG_LEVEL=error # Enable error logging -``` - -## CipherStash Client - -Protect.js is built on top of the CipherStash Client Rust SDK which is embedded with the `@cipherstash/protect-ffi` package. -The `@cipherstash/protect-ffi` source code is available on [GitHub](https://github.com/cipherstash/stack-ffi). - -Read more about configuring the CipherStash Client in the [configuration docs](https://cipherstash.com/docs/stack/reference). - -## Example applications - -Looking for examples of how to use Protect.js? -Check out the [example applications](./examples): - -- [Basic example](/examples/basic) demonstrates how to perform encryption operations -- [Drizzle example](/examples/drizzle) demonstrates how to use Protect.js with an ORM -- [Next.js and lock contexts example using Clerk](/examples/nextjs-clerk) demonstrates how to protect data with identity-aware encryption - -`@cipherstash/protect` can be used with most ORMs. -If you're interested in using `@cipherstash/protect` with a specific ORM, please [create an issue](https://github.com/cipherstash/stack/issues/new). - -## Builds and bundling - -`@cipherstash/protect` is a native Node.js module, and relies on native Node.js `require` to load the package. - -Here are a few resources to help based on your tool set: - -- [Required Next.js configuration](https://cipherstash.com/docs/stack/deploy/bundling). -- [SST and AWS serverless functions](https://cipherstash.com/docs/stack/deploy/bundling). - -> [!TIP] -> Deploying to Linux (e.g., AWS Lambda) with npm lockfile v3 and seeing runtime module load errors? See the troubleshooting guide: [bundling guide](https://cipherstash.com/docs/stack/deploy/bundling). - -## Contributing - -Please read the [contribution guide](CONTRIBUTE.md). - -## License - -Protect.js is [MIT licensed](./LICENSE.md). - ---- - -### Didn't find what you wanted? - -[Click here to let us know what was missing from our docs.](https://github.com/cipherstash/stack/issues/new?template=docs-feedback.yml&title=[Docs:]%20Feedback%20on%20README.md) diff --git a/packages/protect/__tests__/audit.test.ts b/packages/protect/__tests__/audit.test.ts deleted file mode 100644 index 6d508f585..000000000 --- a/packages/protect/__tests__/audit.test.ts +++ /dev/null @@ -1,472 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable } from '@cipherstash/schema' -import { beforeAll, describe, expect, it } from 'vitest' -import { LockContext, protect } from '../src' - -const users = csTable('users', { - auditable: csColumn('auditable'), - email: csColumn('email').freeTextSearch().equality().orderAndRange(), - address: csColumn('address').freeTextSearch(), -}) - -type User = { - id: string - email?: string | null - address?: string | null - auditable?: string | null - createdAt?: Date - updatedAt?: Date - number?: number -} - -let protectClient: Awaited> - -beforeAll(async () => { - protectClient = await protect({ - schemas: [users], - }) -}) - -describe('encryption and decryption with audit', () => { - it('should encrypt and decrypt a payload with audit metadata', async () => { - const email = 'very_secret_data' - - const ciphertext = await protectClient - .encrypt(email, { - column: users.auditable, - table: users, - }) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'encrypt', - }, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const plaintext = await protectClient.decrypt(ciphertext.data).audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'decrypt', - }, - }) - - expect(plaintext).toEqual({ - data: email, - }) - }, 30000) - - it('should encrypt and decrypt a model with audit metadata', async () => { - // Create a model with decrypted values - const decryptedModel: User = { - id: '1', - email: 'test@example.com', - address: '123 Main St', - auditable: 'sensitive_data', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - } - - // Encrypt the model with audit - const encryptedModel = await protectClient - .encryptModel(decryptedModel, users) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'encrypt_model', - }, - }) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.address).toHaveProperty('c') - expect(encryptedModel.data.auditable).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.number).toBe(1) - - // Decrypt the model with audit - const decryptedResult = await protectClient - .decryptModel(encryptedModel.data) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'decrypt_model', - }, - }) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle null values in a model with audit metadata', async () => { - // Create a model with null values - const decryptedModel: User = { - id: '1', - email: null, - address: null, - auditable: null, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - } - - // Encrypt the model with audit - const encryptedModel = await protectClient - .encryptModel(decryptedModel, users) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'encrypt_model_nulls', - }, - }) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify null fields are preserved - expect(encryptedModel.data.email).toBeNull() - expect(encryptedModel.data.address).toBeNull() - expect(encryptedModel.data.auditable).toBeNull() - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.number).toBe(1) - - // Decrypt the model with audit - const decryptedResult = await protectClient - .decryptModel(encryptedModel.data) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'decrypt_model_nulls', - }, - }) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) -}) - -describe('bulk encryption with audit', () => { - it('should bulk encrypt and decrypt models with audit metadata', async () => { - // Create models with decrypted values - const decryptedModels: User[] = [ - { - id: '1', - email: 'test1@example.com', - address: '123 Main St', - auditable: 'sensitive_data_1', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - }, - { - id: '2', - email: 'test2@example.com', - address: '456 Oak St', - auditable: 'sensitive_data_2', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 2, - }, - ] - - // Encrypt the models with audit - const encryptedModels = await protectClient - .bulkEncryptModels(decryptedModels, users) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'bulk_encrypt_models', - }, - }) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields for each model - expect(encryptedModels.data[0].email).toHaveProperty('c') - expect(encryptedModels.data[0].address).toHaveProperty('c') - expect(encryptedModels.data[0].auditable).toHaveProperty('c') - expect(encryptedModels.data[1].email).toHaveProperty('c') - expect(encryptedModels.data[1].address).toHaveProperty('c') - expect(encryptedModels.data[1].auditable).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[0].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].number).toBe(1) - expect(encryptedModels.data[1].id).toBe('2') - expect(encryptedModels.data[1].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].number).toBe(2) - - // Decrypt the models with audit - const decryptedResult = await protectClient - .bulkDecryptModels(encryptedModels.data) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'bulk_decrypt_models', - }, - }) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModels) - }, 30000) - - it('should handle mixed null and non-null values in bulk operations with audit', async () => { - const decryptedModels: User[] = [ - { - id: '1', - email: 'test1@example.com', - address: null, - auditable: 'sensitive_data_1', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - }, - { - id: '2', - email: null, - address: '123 Main St', - auditable: null, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 2, - }, - { - id: '3', - email: 'test3@example.com', - address: '456 Oak St', - auditable: 'sensitive_data_3', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 3, - }, - ] - - // Encrypt the models with audit - const encryptedModels = await protectClient - .bulkEncryptModels(decryptedModels, users) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'bulk_encrypt_mixed_nulls', - }, - }) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields for each model - expect(encryptedModels.data[0].email).toHaveProperty('c') - expect(encryptedModels.data[0].address).toBeNull() - expect(encryptedModels.data[0].auditable).toHaveProperty('c') - expect(encryptedModels.data[1].email).toBeNull() - expect(encryptedModels.data[1].address).toHaveProperty('c') - expect(encryptedModels.data[1].auditable).toBeNull() - expect(encryptedModels.data[2].email).toHaveProperty('c') - expect(encryptedModels.data[2].address).toHaveProperty('c') - expect(encryptedModels.data[2].auditable).toHaveProperty('c') - - // Decrypt the models with audit - const decryptedResult = await protectClient - .bulkDecryptModels(decryptedModels) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'bulk_decrypt_mixed_nulls', - }, - }) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModels) - }, 30000) - - it('should return empty array if models is empty with audit', async () => { - // Encrypt empty array of models with audit - const encryptedModels = await protectClient - .bulkEncryptModels([], users) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'bulk_encrypt_empty', - }, - }) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - expect(encryptedModels.data).toEqual([]) - - // Decrypt empty array of models with audit - const decryptedResult = await protectClient - .bulkDecryptModels([]) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'bulk_decrypt_empty', - }, - }) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual([]) - }, 30000) -}) - -describe('audit with lock context', () => { - it('should encrypt and decrypt a model with both audit and lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - // Create a model with decrypted values - const decryptedModel: User = { - id: '1', - email: 'test@example.com', - auditable: 'sensitive_with_context', - } - - // Encrypt the model with both audit and lock context - const encryptedModel = await protectClient - .encryptModel(decryptedModel, users) - .withLockContext(lockContext.data) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'encrypt_with_context', - }, - }) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Decrypt the model with both audit and lock context - const decryptedResult = await protectClient - .decryptModel(encryptedModel.data) - .withLockContext(lockContext.data) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'decrypt_with_context', - }, - }) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should bulk encrypt and decrypt models with both audit and lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - // Create models with decrypted values - const decryptedModels: User[] = [ - { - id: '1', - email: 'test1@example.com', - auditable: 'bulk_sensitive_1', - }, - { - id: '2', - email: 'test2@example.com', - auditable: 'bulk_sensitive_2', - }, - ] - - // Encrypt the models with both audit and lock context - const encryptedModels = await protectClient - .bulkEncryptModels(decryptedModels, users) - .withLockContext(lockContext.data) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'bulk_encrypt_with_context', - }, - }) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Decrypt the models with both audit and lock context - const decryptedResult = await protectClient - .bulkDecryptModels(encryptedModels.data) - .withLockContext(lockContext.data) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'bulk_decrypt_with_context', - }, - }) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModels) - }, 30000) -}) diff --git a/packages/protect/__tests__/basic-protect.test.ts b/packages/protect/__tests__/basic-protect.test.ts deleted file mode 100644 index 6277a842a..000000000 --- a/packages/protect/__tests__/basic-protect.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable } from '@cipherstash/schema' -import { beforeAll, describe, expect, it } from 'vitest' -import { protect } from '../src' - -const users = csTable('users', { - email: csColumn('email').freeTextSearch().equality().orderAndRange(), - address: csColumn('address').freeTextSearch(), - json: csColumn('json').dataType('json'), -}) - -let protectClient: Awaited> - -beforeAll(async () => { - protectClient = await protect({ - schemas: [users], - }) -}) - -describe('encryption and decryption', () => { - it('should encrypt and decrypt a payload', async () => { - const email = 'hello@example.com' - - const ciphertext = await protectClient.encrypt(email, { - column: users.email, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const a = ciphertext.data - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: email, - }) - }, 30000) -}) diff --git a/packages/protect/__tests__/bulk-protect.test.ts b/packages/protect/__tests__/bulk-protect.test.ts deleted file mode 100644 index 893bea86a..000000000 --- a/packages/protect/__tests__/bulk-protect.test.ts +++ /dev/null @@ -1,597 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable } from '@cipherstash/schema' -import { beforeAll, describe, expect, it } from 'vitest' -import { type EncryptedPayload, LockContext, protect } from '../src' - -const users = csTable('users', { - email: csColumn('email').freeTextSearch().equality().orderAndRange(), - address: csColumn('address').freeTextSearch(), -}) - -type User = { - id: string - email?: string | null - createdAt?: Date - updatedAt?: Date - address?: string | null - number?: number -} - -let protectClient: Awaited> - -beforeAll(async () => { - protectClient = await protect({ - schemas: [users], - }) -}) - -describe('bulk encryption and decryption', () => { - describe('bulk encrypt', () => { - it('should bulk encrypt an array of plaintexts with IDs', async () => { - const plaintexts = [ - { id: 'user1', plaintext: 'alice@example.com' }, - { id: 'user2', plaintext: 'bob@example.com' }, - { id: 'user3', plaintext: 'charlie@example.com' }, - ] - - const encryptedData = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(3) - expect(encryptedData.data[0]).toHaveProperty('id', 'user1') - expect(encryptedData.data[0]).toHaveProperty('data') - expect(encryptedData.data[0].data).toHaveProperty('c') - expect(encryptedData.data[1]).toHaveProperty('id', 'user2') - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[2]).toHaveProperty('id', 'user3') - expect(encryptedData.data[2]).toHaveProperty('data') - - // Verify all encrypted values are different - expect(encryptedData.data[0].data?.c).not.toBe( - encryptedData.data[1].data?.c, - ) - expect(encryptedData.data[1].data?.c).not.toBe( - encryptedData.data[2].data?.c, - ) - expect(encryptedData.data[0].data?.c).not.toBe( - encryptedData.data[2].data?.c, - ) - }, 30000) - - it('should bulk encrypt an array of plaintexts without IDs', async () => { - const plaintexts = [ - { plaintext: 'alice@example.com' }, - { plaintext: 'bob@example.com' }, - { plaintext: 'charlie@example.com' }, - ] - - const encryptedData = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(3) - expect(encryptedData.data[0]).toHaveProperty('id', undefined) - expect(encryptedData.data[0]).toHaveProperty('data') - expect(encryptedData.data[0].data).toHaveProperty('c') - expect(encryptedData.data[1]).toHaveProperty('id', undefined) - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[1].data).toHaveProperty('c') - expect(encryptedData.data[2]).toHaveProperty('id', undefined) - expect(encryptedData.data[2]).toHaveProperty('data') - expect(encryptedData.data[2].data).toHaveProperty('c') - }, 30000) - - it('should handle null values in bulk encrypt', async () => { - const plaintexts = [ - { id: 'user1', plaintext: 'alice@example.com' }, - { id: 'user2', plaintext: null }, - { id: 'user3', plaintext: 'charlie@example.com' }, - ] - - const encryptedData = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(3) - expect(encryptedData.data[0]).toHaveProperty('id', 'user1') - expect(encryptedData.data[0]).toHaveProperty('data') - expect(encryptedData.data[0].data).toHaveProperty('c') - expect(encryptedData.data[1]).toHaveProperty('id', 'user2') - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[1].data).toBeNull() - expect(encryptedData.data[2]).toHaveProperty('id', 'user3') - expect(encryptedData.data[2]).toHaveProperty('data') - expect(encryptedData.data[2].data).toHaveProperty('c') - }, 30000) - - it('should handle all null values in bulk encrypt', async () => { - const plaintexts = [ - { id: 'user1', plaintext: null }, - { id: 'user2', plaintext: null }, - { id: 'user3', plaintext: null }, - ] - - const encryptedData = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(3) - expect(encryptedData.data[0]).toHaveProperty('id', 'user1') - expect(encryptedData.data[0]).toHaveProperty('data') - expect(encryptedData.data[0].data).toBeNull() - expect(encryptedData.data[1]).toHaveProperty('id', 'user2') - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[1].data).toBeNull() - expect(encryptedData.data[2]).toHaveProperty('id', 'user3') - expect(encryptedData.data[2]).toHaveProperty('data') - expect(encryptedData.data[2].data).toBeNull() - }, 30000) - - it('should handle empty array in bulk encrypt', async () => { - const plaintexts: Array<{ id?: string; plaintext: string | null }> = [] - - const encryptedData = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - expect(encryptedData.data).toHaveLength(0) - }, 30000) - }) - - describe('bulk decrypt', () => { - it('should bulk decrypt an array of encrypted payloads with IDs', async () => { - // First encrypt some data - const plaintexts = [ - { id: 'user1', plaintext: 'alice@example.com' }, - { id: 'user2', plaintext: 'bob@example.com' }, - { id: 'user3', plaintext: 'charlie@example.com' }, - ] - - const encryptedData = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Now decrypt the data - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify structure - expect(decryptedData.data).toHaveLength(3) - expect(decryptedData.data[0]).toHaveProperty('id', 'user1') - expect(decryptedData.data[0]).toHaveProperty('data', 'alice@example.com') - expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', 'bob@example.com') - expect(decryptedData.data[2]).toHaveProperty('id', 'user3') - expect(decryptedData.data[2]).toHaveProperty( - 'data', - 'charlie@example.com', - ) - }, 30000) - - it('should bulk decrypt an array of encrypted payloads without IDs', async () => { - // First encrypt some data - const plaintexts = [ - { plaintext: 'alice@example.com' }, - { plaintext: 'bob@example.com' }, - { plaintext: 'charlie@example.com' }, - ] - - const encryptedData = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Now decrypt the data - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify structure - expect(decryptedData.data).toHaveLength(3) - expect(decryptedData.data[0]).toHaveProperty('id', undefined) - expect(decryptedData.data[0]).toHaveProperty('data', 'alice@example.com') - expect(decryptedData.data[1]).toHaveProperty('id', undefined) - expect(decryptedData.data[1]).toHaveProperty('data', 'bob@example.com') - expect(decryptedData.data[2]).toHaveProperty('id', undefined) - expect(decryptedData.data[2]).toHaveProperty( - 'data', - 'charlie@example.com', - ) - }, 30000) - - it('should handle null values in bulk decrypt', async () => { - // First encrypt some data with nulls - const plaintexts = [ - { id: 'user1', plaintext: 'alice@example.com' }, - { id: 'user2', plaintext: null }, - { id: 'user3', plaintext: 'charlie@example.com' }, - ] - - const encryptedData = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Now decrypt the data - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify structure - expect(decryptedData.data).toHaveLength(3) - expect(decryptedData.data[0]).toHaveProperty('id', 'user1') - expect(decryptedData.data[0]).toHaveProperty('data', 'alice@example.com') - expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', null) - expect(decryptedData.data[2]).toHaveProperty('id', 'user3') - expect(decryptedData.data[2]).toHaveProperty( - 'data', - 'charlie@example.com', - ) - }, 30000) - - it('should handle all null values in bulk decrypt', async () => { - // First encrypt some data with all nulls - const plaintexts = [ - { id: 'user1', plaintext: null }, - { id: 'user2', plaintext: null }, - { id: 'user3', plaintext: null }, - ] - - const encryptedData = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Now decrypt the data - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify structure - expect(decryptedData.data).toHaveLength(3) - expect(decryptedData.data[0]).toHaveProperty('id', 'user1') - expect(decryptedData.data[0]).toHaveProperty('data', null) - expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', null) - expect(decryptedData.data[2]).toHaveProperty('id', 'user3') - expect(decryptedData.data[2]).toHaveProperty('data', null) - }, 30000) - - it('should handle empty array in bulk decrypt', async () => { - const encryptedPayloads: Array<{ id?: string; data: EncryptedPayload }> = - [] - - const decryptedData = await protectClient.bulkDecrypt(encryptedPayloads) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - expect(decryptedData.data).toHaveLength(0) - }, 30000) - }) - - describe('bulk operations with lock context', () => { - it('should bulk encrypt and decrypt with lock context', async () => { - // This test requires a valid JWT token, so we'll skip it in CI - // TODO: Add proper JWT token handling for CI - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - const plaintexts = [ - { id: 'user1', plaintext: 'alice@example.com' }, - { id: 'user2', plaintext: 'bob@example.com' }, - { id: 'user3', plaintext: 'charlie@example.com' }, - ] - - // Encrypt with lock context - const encryptedData = await protectClient - .bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - .withLockContext(lockContext.data) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(3) - expect(encryptedData.data[0]).toHaveProperty('id', 'user1') - expect(encryptedData.data[0]).toHaveProperty('data') - expect(encryptedData.data[0].data).toHaveProperty('c') - expect(encryptedData.data[1]).toHaveProperty('id', 'user2') - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[1].data).toHaveProperty('c') - expect(encryptedData.data[2]).toHaveProperty('id', 'user3') - expect(encryptedData.data[2]).toHaveProperty('data') - expect(encryptedData.data[2].data).toHaveProperty('c') - - // Decrypt with lock context - const decryptedData = await protectClient - .bulkDecrypt(encryptedData.data) - .withLockContext(lockContext.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify decrypted data - expect(decryptedData.data).toHaveLength(3) - expect(decryptedData.data[0]).toHaveProperty('id', 'user1') - expect(decryptedData.data[0]).toHaveProperty('data', 'alice@example.com') - expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', 'bob@example.com') - expect(decryptedData.data[2]).toHaveProperty('id', 'user3') - expect(decryptedData.data[2]).toHaveProperty( - 'data', - 'charlie@example.com', - ) - }, 30000) - - it('should handle null values with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - const plaintexts = [ - { id: 'user1', plaintext: 'alice@example.com' }, - { id: 'user2', plaintext: null }, - { id: 'user3', plaintext: 'charlie@example.com' }, - ] - - // Encrypt with lock context - const encryptedData = await protectClient - .bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - .withLockContext(lockContext.data) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify null is preserved - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[1].data).toBeNull() - - // Decrypt with lock context - const decryptedData = await protectClient - .bulkDecrypt(encryptedData.data) - .withLockContext(lockContext.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify null is preserved - expect(decryptedData.data[1]).toHaveProperty('data') - expect(decryptedData.data[1].data).toBeNull() - }, 30000) - - it('should decrypt mixed lock context payloads with specific lock context', async () => { - const userJwt = process.env.USER_JWT - const user2Jwt = process.env.USER_2_JWT - - if (!userJwt || !user2Jwt) { - console.log( - 'Skipping mixed lock context test - missing USER_JWT or USER_2_JWT', - ) - return - } - - const lc = new LockContext() - const lc2 = new LockContext() - const lockContext1 = await lc.identify(userJwt) - const lockContext2 = await lc2.identify(user2Jwt) - - if (lockContext1.failure) { - throw new Error(`[protect]: ${lockContext1.failure.message}`) - } - - if (lockContext2.failure) { - throw new Error(`[protect]: ${lockContext2.failure.message}`) - } - - // Encrypt first value with USER_JWT lock context - const encryptedData1 = await protectClient - .bulkEncrypt([{ id: 'user1', plaintext: 'alice@example.com' }], { - column: users.email, - table: users, - }) - .withLockContext(lockContext1.data) - - if (encryptedData1.failure) { - throw new Error(`[protect]: ${encryptedData1.failure.message}`) - } - - // Encrypt second value with USER_2_JWT lock context - const encryptedData2 = await protectClient - .bulkEncrypt([{ id: 'user2', plaintext: 'bob@example.com' }], { - column: users.email, - table: users, - }) - .withLockContext(lockContext2.data) - - if (encryptedData2.failure) { - throw new Error(`[protect]: ${encryptedData2.failure.message}`) - } - - // Combine both encrypted payloads - const combinedEncryptedData = [ - ...encryptedData1.data, - ...encryptedData2.data, - ] - - // Decrypt with USER_2_JWT lock context - const decryptedData = await protectClient - .bulkDecrypt(combinedEncryptedData) - .withLockContext(lockContext2.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify both payloads are returned - expect(decryptedData.data).toHaveLength(2) - - // First payload should fail to decrypt since it was encrypted with different lock context - expect(decryptedData.data[0]).toHaveProperty('id', 'user1') - expect(decryptedData.data[0]).toHaveProperty('error') - expect(decryptedData.data[0]).not.toHaveProperty('data') - - // Second payload should be decrypted since it was encrypted with the same lock context - expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', 'bob@example.com') - expect(decryptedData.data[1]).not.toHaveProperty('error') - }, 30000) - }) - - describe('bulk operations round-trip', () => { - it('should maintain data integrity through encrypt/decrypt cycle', async () => { - const originalData = [ - { id: 'user1', plaintext: 'alice@example.com' }, - { id: 'user2', plaintext: 'bob@example.com' }, - { id: 'user3', plaintext: null }, - { id: 'user4', plaintext: 'dave@example.com' }, - ] - - // Encrypt - const encryptedData = await protectClient.bulkEncrypt(originalData, { - column: users.email, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Decrypt - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify round-trip integrity - expect(decryptedData.data).toHaveLength(originalData.length) - - for (let i = 0; i < originalData.length; i++) { - expect(decryptedData.data[i].id).toBe(originalData[i].id) - expect(decryptedData.data[i].data).toBe(originalData[i].plaintext) - } - }, 30000) - - it('should handle large arrays efficiently', async () => { - const originalData = Array.from({ length: 100 }, (_, i) => ({ - id: `user${i}`, - plaintext: `user${i}@example.com`, - })) - - // Encrypt - const encryptedData = await protectClient.bulkEncrypt(originalData, { - column: users.email, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Decrypt - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify all data is preserved - expect(decryptedData.data).toHaveLength(100) - - for (let i = 0; i < 100; i++) { - expect(decryptedData.data[i].id).toBe(`user${i}`) - expect(decryptedData.data[i].data).toBe(`user${i}@example.com`) - } - }, 30000) - }) -}) diff --git a/packages/protect/__tests__/deprecated/search-terms.test.ts b/packages/protect/__tests__/deprecated/search-terms.test.ts deleted file mode 100644 index d3c12a773..000000000 --- a/packages/protect/__tests__/deprecated/search-terms.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable } from '@cipherstash/schema' -import { describe, expect, it } from 'vitest' -import { protect, type SearchTerm } from '../../src' - -const users = csTable('users', { - email: csColumn('email').freeTextSearch().equality().orderAndRange(), - address: csColumn('address').freeTextSearch(), - age: csColumn('age').dataType('number').equality(), - score: csColumn('score').dataType('number').equality(), -}) - -describe('createSearchTerms (deprecated - backward compatibility)', () => { - it('should create search terms with default return type', async () => { - const protectClient = await protect({ schemas: [users] }) - - const searchTerms = [ - { - value: 'hello', - column: users.email, - table: users, - }, - { - value: 'world', - column: users.address, - table: users, - }, - ] as SearchTerm[] - - const searchTermsResult = await protectClient.createSearchTerms(searchTerms) - - if (searchTermsResult.failure) { - throw new Error(`[protect]: ${searchTermsResult.failure.message}`) - } - - expect(searchTermsResult.data).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - v: 2, - }), - ]), - ) - }, 30000) - - it('should create search terms with composite-literal return type', async () => { - const protectClient = await protect({ schemas: [users] }) - - const searchTerms = [ - { - value: 'hello', - column: users.email, - table: users, - returnType: 'composite-literal', - }, - ] as SearchTerm[] - - const searchTermsResult = await protectClient.createSearchTerms(searchTerms) - - if (searchTermsResult.failure) { - throw new Error(`[protect]: ${searchTermsResult.failure.message}`) - } - - const result = searchTermsResult.data[0] as string - expect(result).toMatch(/^\(.*\)$/) - expect(() => JSON.parse(result.slice(1, -1))).not.toThrow() - }, 30000) - - it('should create search terms with escaped-composite-literal return type', async () => { - const protectClient = await protect({ schemas: [users] }) - - const searchTerms = [ - { - value: 'hello', - column: users.email, - table: users, - returnType: 'escaped-composite-literal', - }, - ] as SearchTerm[] - - const searchTermsResult = await protectClient.createSearchTerms(searchTerms) - - if (searchTermsResult.failure) { - throw new Error(`[protect]: ${searchTermsResult.failure.message}`) - } - - const result = searchTermsResult.data[0] as string - expect(result).toMatch(/^".*"$/) - const unescaped = JSON.parse(result) - expect(unescaped).toMatch(/^\(.*\)$/) - expect(() => JSON.parse(unescaped.slice(1, -1))).not.toThrow() - }, 30000) - - it('should create search terms with composite-literal return type for numbers', async () => { - const protectClient = await protect({ schemas: [users] }) - - const searchTerms = [ - { - value: 42, - column: users.age, - table: users, - returnType: 'composite-literal' as const, - }, - ] - - const searchTermsResult = await protectClient.createSearchTerms(searchTerms) - - if (searchTermsResult.failure) { - throw new Error(`[protect]: ${searchTermsResult.failure.message}`) - } - - const result = searchTermsResult.data[0] as string - expect(result).toMatch(/^\(.*\)$/) - expect(() => JSON.parse(result.slice(1, -1))).not.toThrow() - }, 30000) - - it('should create search terms with escaped-composite-literal return type for numbers', async () => { - const protectClient = await protect({ schemas: [users] }) - - const searchTerms = [ - { - value: 99, - column: users.score, - table: users, - returnType: 'escaped-composite-literal' as const, - }, - ] - - const searchTermsResult = await protectClient.createSearchTerms(searchTerms) - - if (searchTermsResult.failure) { - throw new Error(`[protect]: ${searchTermsResult.failure.message}`) - } - - const result = searchTermsResult.data[0] as string - expect(result).toMatch(/^".*"$/) - const unescaped = JSON.parse(result) - expect(unescaped).toMatch(/^\(.*\)$/) - expect(() => JSON.parse(unescaped.slice(1, -1))).not.toThrow() - }, 30000) -}) diff --git a/packages/protect/__tests__/encrypt-query-searchable-json.test.ts b/packages/protect/__tests__/encrypt-query-searchable-json.test.ts deleted file mode 100644 index faa25cb84..000000000 --- a/packages/protect/__tests__/encrypt-query-searchable-json.test.ts +++ /dev/null @@ -1,1061 +0,0 @@ -import 'dotenv/config' -import { beforeAll, describe, expect, it } from 'vitest' -import { ProtectErrorTypes, protect } from '../src' - -type ProtectClient = Awaited> - -import { - createFailingMockLockContext, - createMockLockContext, - createMockLockContextWithNullContext, - expectFailure, - jsonbSchema, - metadata, - unwrapResult, -} from './fixtures' - -/* - * The `searchableJson` queryType provides a friendlier API by auto-inferring the - * underlying query operation from the plaintext type. It's equivalent to omitting - * queryType on ste_vec columns, but explicit for code clarity. - * - String values → ste_vec_selector (JSONPath queries) - * - Object/Array values → ste_vec_term (containment queries) - */ - -/** Assert encrypted selector output has valid shape */ -function expectSelector(data: any) { - expect(data).toHaveProperty('s') - expect(typeof data.s).toBe('string') - expect(data.s.length).toBeGreaterThan(0) -} - -/** Assert encrypted term output has valid shape */ -function expectTerm(data: any) { - expect(data).toHaveProperty('sv') - expect(Array.isArray(data.sv)).toBe(true) -} - -describe('encryptQuery with searchableJson queryType', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema, metadata] }) - }) - - // Core functionality: auto-inference from plaintext type - - it('auto-infers ste_vec_selector for string plaintext (JSONPath)', async () => { - const result = await protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectSelector(data) - }, 30000) - - it('auto-infers ste_vec_term for object plaintext (containment)', async () => { - const result = await protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectTerm(data) - }, 30000) - - it('auto-infers ste_vec_term for nested object', async () => { - const result = await protectClient.encryptQuery( - { user: { profile: { role: 'admin' } } }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectTerm(data) - }, 30000) - - it('auto-infers ste_vec_term for array plaintext', async () => { - const result = await protectClient.encryptQuery(['admin', 'user'], { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectTerm(data) - }, 30000) - - it('returns null for null plaintext', async () => { - const result = await protectClient.encryptQuery(null, { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const data = unwrapResult(result) - expect(data).toBeNull() - }, 30000) - - // Edge cases: number/boolean require wrapping (same as steVecTerm) - - it('fails for bare number plaintext (requires wrapping)', async () => { - const result = await protectClient.encryptQuery(42, { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - expectFailure(result, /Wrap the number in a JSON object/) - }, 30000) - - it('fails for bare boolean plaintext (requires wrapping)', async () => { - const result = await protectClient.encryptQuery(true, { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - expectFailure(result, /Wrap the boolean in a JSON object/) - }, 30000) -}) - -describe('encryptQuery with searchableJson column and omitted queryType', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema, metadata] }) - }) - - it('auto-infers ste_vec_selector for string plaintext (JSONPath)', async () => { - const result = await protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - }) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectSelector(data) - }, 30000) - - it('auto-infers ste_vec_term for object plaintext (containment)', async () => { - const result = await protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - }, - ) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectTerm(data) - }, 30000) - - it('returns null for null plaintext', async () => { - const result = await protectClient.encryptQuery(null, { - column: jsonbSchema.metadata, - table: jsonbSchema, - }) - - const data = unwrapResult(result) - expect(data).toBeNull() - }, 30000) - - it('fails for bare number plaintext (requires wrapping)', async () => { - const result = await protectClient.encryptQuery(42, { - column: jsonbSchema.metadata, - table: jsonbSchema, - }) - - expectFailure(result, /Wrap the number in a JSON object/) - }, 30000) - - it('fails for bare boolean plaintext (requires wrapping)', async () => { - const result = await protectClient.encryptQuery(true, { - column: jsonbSchema.metadata, - table: jsonbSchema, - }) - - expectFailure(result, /Wrap the boolean in a JSON object/) - }, 30000) -}) - -describe('searchableJson validation', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema, metadata] }) - }) - - it('throws when used on column without ste_vec index', async () => { - const result = await protectClient.encryptQuery('$.path', { - column: metadata.raw, // raw column has no ste_vec index - table: metadata, - queryType: 'searchableJson', - }) - - expectFailure(result) - }, 30000) -}) - -describe('searchableJson batch operations', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema] }) - }) - - it('handles mixed plaintext types in single batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: '$.user.email', // string → ste_vec_selector - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - { - value: { role: 'admin' }, // object → ste_vec_term - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - { - value: ['tag1', 'tag2'], // array → ste_vec_term - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ]) - - const data = unwrapResult(result) - expect(data).toHaveLength(3) - expect(data[0]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) - expectSelector(data[0]) - expect(data[1]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) - expectTerm(data[1]) - expect(data[2]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) - expectTerm(data[2]) - }, 30000) - - it('handles null values in batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: null, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - { - value: '$.user.email', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - { - value: null, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ]) - - const data = unwrapResult(result) - expect(data).toHaveLength(3) - expect(data[0]).toBeNull() - expect(data[1]).not.toBeNull() - expectSelector(data[1]) - expect(data[2]).toBeNull() - }, 30000) - - it('can be mixed with explicit steVecSelector/steVecTerm in batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: '$.path1', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', // auto-infer - }, - { - value: '$.path2', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecSelector', // explicit - }, - { - value: { key: 'value' }, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecTerm', // explicit - }, - ]) - - const data = unwrapResult(result) - expect(data).toHaveLength(3) - expectSelector(data[0]) - expectSelector(data[1]) - expectTerm(data[2]) - }, 30000) - - it('can omit queryType for searchableJson in batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: '$.path1', - column: jsonbSchema.metadata, - table: jsonbSchema, - }, - { - value: { key: 'value' }, - column: jsonbSchema.metadata, - table: jsonbSchema, - }, - { - value: null, - column: jsonbSchema.metadata, - table: jsonbSchema, - }, - ]) - - const data = unwrapResult(result) - expect(data).toHaveLength(3) - expectSelector(data[0]) - expectTerm(data[1]) - expect(data[2]).toBeNull() - }, 30000) -}) - -describe('searchableJson with returnType formatting', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema] }) - }) - - it('supports composite-literal returnType', async () => { - const result = await protectClient.encryptQuery([ - { - value: '$.user.email', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - returnType: 'composite-literal', - }, - ]) - - const data = unwrapResult(result) - expect(data).toHaveLength(1) - expect(typeof data[0]).toBe('string') - // Format: ("json") - expect(data[0]).toMatch(/^\(".*"\)$/) - }, 30000) - - it('supports escaped-composite-literal returnType', async () => { - const result = await protectClient.encryptQuery([ - { - value: { role: 'admin' }, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - returnType: 'escaped-composite-literal', - }, - ]) - - const data = unwrapResult(result) - expect(data).toHaveLength(1) - expect(typeof data[0]).toBe('string') - // Format: "(\"json\")" - outer quotes with escaped inner quotes - expect(data[0]).toMatch(/^"\(.*\)"$/) - }, 30000) - - describe('single-value returnType', () => { - it('returns composite-literal for selector', async () => { - const result = await protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - returnType: 'composite-literal', - }) - - const data = unwrapResult(result) - expect(typeof data).toBe('string') - expect(data).toMatch(/^\(".*"\)$/) - }, 30000) - - it('returns composite-literal for term', async () => { - const result = await protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - returnType: 'composite-literal', - }, - ) - - const data = unwrapResult(result) - expect(typeof data).toBe('string') - expect(data).toMatch(/^\(".*"\)$/) - }, 30000) - - it('returns escaped-composite-literal for selector', async () => { - const result = await protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - returnType: 'escaped-composite-literal', - }) - - const data = unwrapResult(result) - expect(typeof data).toBe('string') - expect(data as string).toMatch(/^"\(.*\)"$/) - // JSON.parse should yield the composite-literal format - const parsed = JSON.parse(data as string) - expect(parsed).toMatch(/^\(.*\)$/) - }, 30000) - - it('returns escaped-composite-literal for term', async () => { - const result = await protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - returnType: 'escaped-composite-literal', - }, - ) - - const data = unwrapResult(result) - expect(typeof data).toBe('string') - expect(data as string).toMatch(/^"\(.*\)"$/) - const parsed = JSON.parse(data as string) - expect(parsed).toMatch(/^\(.*\)$/) - }, 30000) - - it('returns Encrypted object when returnType is omitted', async () => { - const result = await protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const data = unwrapResult(result) as any - expect(typeof data).toBe('object') - expect(data).toHaveProperty('i') - expect(data.i).toHaveProperty('t') - expect(data.i).toHaveProperty('c') - }, 30000) - }) -}) - -describe('searchableJson with LockContext', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema] }) - }) - - it('exposes withLockContext method', async () => { - const operation = protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - expect(operation.withLockContext).toBeDefined() - expect(typeof operation.withLockContext).toBe('function') - }) - - it('executes string plaintext with LockContext mock', async () => { - const mockLockContext = createMockLockContext() - - const operation = protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectSelector(data) - }, 30000) - - it('executes object plaintext with LockContext mock', async () => { - const mockLockContext = createMockLockContext() - - const operation = protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - // LockContext should be called even if the actual encryption fails - // with a mock token (ste_vec_term operations may require real auth) - expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1) - - // Ensure the operation actually completed (has either data or failure) - expect(result.data !== undefined || result.failure !== undefined).toBe(true) - - // The result may fail due to mock token, but we verify LockContext integration worked - if (result.data) { - expect(result.data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectTerm(result.data) - } - }, 30000) - - it('executes batch with LockContext mock', async () => { - const mockLockContext = createMockLockContext() - - const operation = protectClient.encryptQuery([ - { - value: '$.user.email', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - { - value: { role: 'admin' }, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ]) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - // LockContext should be called even if the actual encryption fails - // with a mock token (ste_vec_term operations may require real auth) - expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1) - - // The result may fail due to mock token, but we verify LockContext integration worked - if (result.data) { - expect(result.data).toHaveLength(2) - } - }, 30000) - - it('handles LockContext failure gracefully', async () => { - const mockLockContext = createFailingMockLockContext( - ProtectErrorTypes.CtsTokenError, - 'Mock LockContext failure', - ) - - const operation = protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - expectFailure( - result, - 'Mock LockContext failure', - ProtectErrorTypes.CtsTokenError, - ) - }, 30000) - - it('handles null value with LockContext', async () => { - const mockLockContext = createMockLockContext() - - const operation = protectClient.encryptQuery(null, { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - // Null values should return null without calling LockContext - // since there's nothing to encrypt - expect(mockLockContext.getLockContext).not.toHaveBeenCalled() - const data = unwrapResult(result) - expect(data).toBeNull() - }, 30000) - - it('handles explicit null context from getLockContext gracefully', async () => { - const mockLockContext = createMockLockContextWithNullContext() - - const operation = protectClient.encryptQuery([ - { - value: '$.user.email', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ]) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - // Should succeed - null context should not be passed to FFI - const data = unwrapResult(result) - expect(data).toHaveLength(1) - expect(data[0]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) - expectSelector(data[0]) - }, 30000) -}) - -describe('searchableJson equivalence', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema] }) - }) - - it('produces identical metadata to omitting queryType for string', async () => { - const explicitResult = await protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const implicitResult = await protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - }) - - // Both should succeed and have identical metadata structure - const explicitData = unwrapResult(explicitResult) - const implicitData = unwrapResult(implicitResult) - - expect(explicitData.i).toEqual(implicitData.i) - expect(explicitData.v).toEqual(implicitData.v) - expectSelector(explicitData) - expectSelector(implicitData) - }, 30000) - - it('produces identical metadata to omitting queryType for object', async () => { - const explicitResult = await protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ) - - const implicitResult = await protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - }, - ) - - const explicitData = unwrapResult(explicitResult) - const implicitData = unwrapResult(implicitResult) - - expect(explicitData.i).toEqual(implicitData.i) - expect(explicitData.v).toEqual(implicitData.v) - expectTerm(explicitData) - expectTerm(implicitData) - }, 30000) - - it('produces identical metadata to explicit steVecSelector for string', async () => { - const searchableJsonResult = await protectClient.encryptQuery( - '$.user.email', - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ) - - const steVecSelectorResult = await protectClient.encryptQuery( - '$.user.email', - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecSelector', - }, - ) - - const searchableJsonData = unwrapResult(searchableJsonResult) - const steVecSelectorData = unwrapResult(steVecSelectorResult) - - expect(searchableJsonData.i).toEqual(steVecSelectorData.i) - expect(searchableJsonData.v).toEqual(steVecSelectorData.v) - expectSelector(searchableJsonData) - expectSelector(steVecSelectorData) - }, 30000) - - it('produces identical metadata to explicit steVecTerm for object', async () => { - const searchableJsonResult = await protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ) - - const steVecTermResult = await protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecTerm', - }, - ) - - const searchableJsonData = unwrapResult(searchableJsonResult) - const steVecTermData = unwrapResult(steVecTermResult) - - expect(searchableJsonData.i).toEqual(steVecTermData.i) - expect(searchableJsonData.v).toEqual(steVecTermData.v) - expectTerm(searchableJsonData) - expectTerm(steVecTermData) - }, 30000) -}) - -describe('searchableJson edge cases', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema] }) - }) - - // Valid edge cases that should succeed - - it('succeeds for empty object', async () => { - const result = await protectClient.encryptQuery( - {}, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectTerm(data) - }, 30000) - - it('succeeds for empty array', async () => { - const result = await protectClient.encryptQuery([], { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectTerm(data) - }, 30000) - - it('succeeds for object with wrapped number', async () => { - const result = await protectClient.encryptQuery( - { value: 42 }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectTerm(data) - }, 30000) - - it('succeeds for object with wrapped boolean', async () => { - const result = await protectClient.encryptQuery( - { active: true }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectTerm(data) - }, 30000) - - it('succeeds for object with null value', async () => { - const result = await protectClient.encryptQuery( - { field: null }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectTerm(data) - }, 30000) - - it('succeeds for deeply nested object (3+ levels)', async () => { - const result = await protectClient.encryptQuery( - { - level1: { - level2: { - level3: { - level4: { - value: 'deep', - }, - }, - }, - }, - }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectTerm(data) - }, 30000) - - // String edge cases for JSONPath selectors - - it('succeeds for JSONPath with array index notation', async () => { - const result = await protectClient.encryptQuery('$.items[0].name', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectSelector(data) - }, 30000) - - it('succeeds for JSONPath with wildcard', async () => { - const result = await protectClient.encryptQuery('$.items[*].name', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectSelector(data) - }, 30000) -}) - -describe('searchableJson batch edge cases', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema] }) - }) - - it('handles single-item batch identically to scalar', async () => { - const scalarResult = await protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const batchResult = await protectClient.encryptQuery([ - { - value: '$.user.email', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ]) - - const scalarData = unwrapResult(scalarResult) - const batchData = unwrapResult(batchResult) - - expect(batchData).toHaveLength(1) - expect(batchData[0].i).toEqual(scalarData.i) - expect(batchData[0].v).toEqual(scalarData.v) - expectSelector(scalarData) - expectSelector(batchData[0]) - }, 30000) - - it('handles all-null batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: null, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - { - value: null, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - { - value: null, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ]) - - const data = unwrapResult(result) - expect(data).toHaveLength(3) - expect(data[0]).toBeNull() - expect(data[1]).toBeNull() - expect(data[2]).toBeNull() - }, 30000) - - it('handles empty batch', async () => { - const result = await protectClient.encryptQuery([]) - - const data = unwrapResult(result) - expect(data).toHaveLength(0) - }, 30000) - - it('handles large batch (10+ items)', async () => { - const items = Array.from({ length: 12 }, (_, i) => ({ - value: i % 2 === 0 ? `$.path${i}` : { index: i }, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson' as const, - })) - - const result = await protectClient.encryptQuery(items) - - const data = unwrapResult(result) - expect(data).toHaveLength(12) - data.forEach((item: any, idx: number) => { - expect(item).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - if (idx % 2 === 0) { - expectSelector(item) - } else { - expectTerm(item) - } - }) - }, 30000) - - it('handles multiple interspersed nulls at various positions', async () => { - const result = await protectClient.encryptQuery([ - { - value: null, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - { - value: '$.user.email', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - { - value: null, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - { - value: { role: 'admin' }, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - { - value: null, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ]) - - const data = unwrapResult(result) - expect(data).toHaveLength(5) - expect(data[0]).toBeNull() - expect(data[1]).not.toBeNull() - expectSelector(data[1]) - expect(data[2]).toBeNull() - expect(data[3]).not.toBeNull() - expectTerm(data[3]) - expect(data[4]).toBeNull() - }, 30000) -}) diff --git a/packages/protect/__tests__/encrypt-query-stevec.test.ts b/packages/protect/__tests__/encrypt-query-stevec.test.ts deleted file mode 100644 index 7c536d725..000000000 --- a/packages/protect/__tests__/encrypt-query-stevec.test.ts +++ /dev/null @@ -1,392 +0,0 @@ -import 'dotenv/config' -import { beforeAll, describe, expect, it } from 'vitest' -import { protect } from '../src' - -type ProtectClient = Awaited> - -import { expectFailure, jsonbSchema, metadata, unwrapResult } from './fixtures' - -describe('encryptQuery with steVecSelector', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema, metadata] }) - }) - - it('encrypts a JSONPath selector', async () => { - const result = await protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecSelector', - }) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - }) - }, 30000) - - it('encrypts nested path selector', async () => { - const result = await protectClient.encryptQuery('$.user.profile.settings', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecSelector', - }) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - }) - }, 30000) - - it('fails for non-string plaintext with steVecSelector (object)', async () => { - const result = await protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecSelector', - }, - ) - - expectFailure(result) - }, 30000) -}) - -describe('encryptQuery with steVecTerm', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema, metadata] }) - }) - - it('encrypts an object for containment query', async () => { - const result = await protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecTerm', - }, - ) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - }) - }, 30000) - - it('encrypts nested object for containment', async () => { - const result = await protectClient.encryptQuery( - { user: { profile: { role: 'admin' } } }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecTerm', - }, - ) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - }) - }, 30000) - - it('encrypts array for containment query', async () => { - const result = await protectClient.encryptQuery([1, 2, 3], { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecTerm', - }) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - }) - }, 30000) - - it('rejects string plaintext with steVecTerm', async () => { - // steVecTerm requires object or array, not string - // For path queries like '$.field', use steVecSelector instead - const result = await protectClient.encryptQuery('search text', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecTerm', - }) - - expectFailure(result, /expected JSON object or array/) - }, 30000) -}) - -describe('encryptQuery STE Vec validation', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema, metadata] }) - }) - - it('throws when steVecSelector used on non-ste_vec column', async () => { - const result = await protectClient.encryptQuery('$.user.email', { - column: metadata.raw, // raw column has no ste_vec index - table: metadata, - queryType: 'steVecSelector', - }) - - expectFailure(result) - }, 30000) - - it('throws when steVecTerm used on non-ste_vec column', async () => { - const result = await protectClient.encryptQuery( - { field: 'value' }, - { - column: metadata.raw, // raw column has no ste_vec index - table: metadata, - queryType: 'steVecTerm', - }, - ) - - expectFailure(result) - }, 30000) -}) - -describe('encryptQuery batch with STE Vec', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema, metadata] }) - }) - - it('handles mixed query types in batch (steVecSelector + steVecTerm)', async () => { - const result = await protectClient.encryptQuery([ - { - value: '$.user.email', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecSelector', - }, - { - value: { role: 'admin' }, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecTerm', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(2) - expect(data[0]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) - expect(data[1]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) - }, 30000) - - it('handles multiple steVecSelector queries in batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: '$.user.email', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecSelector', - }, - { - value: '$.settings.theme', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecSelector', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(2) - expect(data[0]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) - expect(data[1]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) - }, 30000) - - it('handles null values with steVecSelector in batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: null, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecSelector', - }, - { - value: '$.user.email', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecSelector', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(2) - expect(data[0]).toBeNull() - expect(data[1]).not.toBeNull() - expect(data[1]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) - }, 30000) - - it('handles null values with steVecTerm in batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: null, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecTerm', - }, - { - value: { role: 'admin' }, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecTerm', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(2) - expect(data[0]).toBeNull() - expect(data[1]).not.toBeNull() - expect(data[1]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) - }, 30000) -}) - -describe('encryptQuery with queryType inference', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema] }) - }) - - it('infers steVecSelector for string plaintext without queryType', async () => { - const result = await protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - // No queryType - should infer steVecSelector from string - }) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - }) - }, 30000) - - it('infers steVecTerm for object plaintext without queryType', async () => { - const result = await protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - // No queryType - should infer steVecTerm from object - }, - ) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - }) - }, 30000) - - it('infers steVecTerm for array plaintext without queryType', async () => { - const result = await protectClient.encryptQuery(['admin', 'user'], { - column: jsonbSchema.metadata, - table: jsonbSchema, - // No queryType - should infer steVecTerm from array - }) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - }) - }, 30000) - - it('infers steVecTerm for number plaintext but FFI requires wrapping', async () => { - // Numbers infer steVecTerm but FFI requires wrapping in object/array - const result = await protectClient.encryptQuery(42, { - column: jsonbSchema.metadata, - table: jsonbSchema, - // No queryType - infers steVecTerm, FFI rejects with helpful message - }) - - expectFailure(result, /Wrap the number in a JSON object/) - }, 30000) - - it('infers steVecTerm for boolean plaintext but FFI requires wrapping', async () => { - // Booleans infer steVecTerm but FFI requires wrapping in object/array - const result = await protectClient.encryptQuery(true, { - column: jsonbSchema.metadata, - table: jsonbSchema, - // No queryType - infers steVecTerm, FFI rejects with helpful message - }) - - expectFailure(result, /Wrap the boolean in a JSON object/) - }, 30000) - - it('returns null for null plaintext (no inference needed)', async () => { - const result = await protectClient.encryptQuery(null, { - column: jsonbSchema.metadata, - table: jsonbSchema, - // No queryType and null plaintext - should return null - }) - - // Null returns null, doesn't throw - const data = unwrapResult(result) - expect(data).toBeNull() - }, 30000) - - it('uses explicit queryType over plaintext inference', async () => { - // String plaintext would normally infer steVecSelector, but explicit steVecTerm should be used - // Note: steVecTerm with string fails FFI validation, so we test the opposite direction - // Using a number (which would infer steVecTerm) with explicit steVecSelector would also fail - // So we verify with array + steVecTerm (already tested) and trust unit test coverage for precedence - const result = await protectClient.encryptQuery([42], { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecTerm', // Explicit - matches inference but proves explicit path works - }) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - }) - }, 30000) -}) - -describe('encryptQuery batch with queryType inference', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema] }) - }) - - it('infers queryOp for each term independently in batch', async () => { - const results = await protectClient.encryptQuery([ - { - value: '$.user.email', // string → steVecSelector - column: jsonbSchema.metadata, - table: jsonbSchema, - // No queryType - }, - { - value: { role: 'admin' }, // object → steVecTerm - column: jsonbSchema.metadata, - table: jsonbSchema, - // No queryType - }, - ]) - - const data = unwrapResult(results) - expect(data).toHaveLength(2) - expect(data[0]).toBeDefined() - expect(data[1]).toBeDefined() - }, 30000) -}) diff --git a/packages/protect/__tests__/encrypt-query.test.ts b/packages/protect/__tests__/encrypt-query.test.ts deleted file mode 100644 index 1f64000b2..000000000 --- a/packages/protect/__tests__/encrypt-query.test.ts +++ /dev/null @@ -1,946 +0,0 @@ -import 'dotenv/config' -import { beforeAll, describe, expect, it } from 'vitest' -import { ProtectErrorTypes, protect } from '../src' -import type { ProtectClient } from '../src/ffi' -import { - articles, - createFailingMockLockContext, - createMockLockContext, - createMockLockContextWithNullContext, - expectFailure, - metadata, - products, - unwrapResult, - users, -} from './fixtures' - -describe('encryptQuery', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ - schemas: [users, articles, products, metadata], - }) - }) - - describe('single value encryption with explicit queryType', () => { - it('encrypts for equality query type', async () => { - const result = await protectClient.encryptQuery('test@example.com', { - column: users.email, - table: users, - queryType: 'equality', - }) - - const data = unwrapResult(result) - - expect(data).toMatchObject({ - i: { t: 'users', c: 'email' }, - v: 2, - }) - expect(data).toHaveProperty('hm') - }, 30000) - - it('encrypts for freeTextSearch query type', async () => { - const result = await protectClient.encryptQuery('hello world', { - column: users.bio, - table: users, - queryType: 'freeTextSearch', - }) - - const data = unwrapResult(result) - - expect(data).toMatchObject({ - i: { t: 'users', c: 'bio' }, - v: 2, - }) - expect(data).toHaveProperty('bf') - }, 30000) - - it('encrypts for orderAndRange query type', async () => { - const result = await protectClient.encryptQuery(25, { - column: users.age, - table: users, - queryType: 'orderAndRange', - }) - - const data = unwrapResult(result) - - expect(data).toMatchObject({ - i: { t: 'users', c: 'age' }, - v: 2, - }) - expect(data).toHaveProperty('ob') - }, 30000) - }) - - describe('auto-inference when queryType omitted', () => { - it('auto-infers equality for column with .equality()', async () => { - const result = await protectClient.encryptQuery('test@example.com', { - column: users.email, - table: users, - }) - - const data = unwrapResult(result) - expect(data).toHaveProperty('hm') - }, 30000) - - it('auto-infers freeTextSearch for match-only column', async () => { - const result = await protectClient.encryptQuery('search content', { - column: articles.content, - table: articles, - }) - - const data = unwrapResult(result) - expect(data).toHaveProperty('bf') - }, 30000) - - it('auto-infers orderAndRange for ore-only column', async () => { - const result = await protectClient.encryptQuery(99.99, { - column: products.price, - table: products, - }) - - const data = unwrapResult(result) - expect(data).toHaveProperty('ob') - }, 30000) - }) - - describe('edge cases', () => { - it('handles null values', async () => { - const result = await protectClient.encryptQuery(null, { - column: users.email, - table: users, - queryType: 'equality', - }) - - const data = unwrapResult(result) - expect(data).toBeNull() - }, 30000) - - it('rejects NaN values', async () => { - const result = await protectClient.encryptQuery(Number.NaN, { - column: users.age, - table: users, - queryType: 'orderAndRange', - }) - - expectFailure(result, 'NaN') - }, 30000) - - it('rejects Infinity values', async () => { - const result = await protectClient.encryptQuery( - Number.POSITIVE_INFINITY, - { - column: users.age, - table: users, - queryType: 'orderAndRange', - }, - ) - - expectFailure(result, 'Infinity') - }, 30000) - - it('rejects negative Infinity values', async () => { - const result = await protectClient.encryptQuery( - Number.NEGATIVE_INFINITY, - { - column: users.age, - table: users, - queryType: 'orderAndRange', - }, - ) - - expectFailure(result, 'Infinity') - }, 30000) - }) - - describe('validation errors', () => { - it('fails when queryType does not match column config', async () => { - const result = await protectClient.encryptQuery('test@example.com', { - column: users.email, - table: users, - queryType: 'freeTextSearch', // email only has equality - }) - - expectFailure(result, 'not configured') - }, 30000) - - it('fails when column has no indexes configured', async () => { - const result = await protectClient.encryptQuery('raw data', { - column: metadata.raw, - table: metadata, - }) - - expectFailure(result, 'no indexes configured') - }, 30000) - - it('provides descriptive error for queryType mismatch', async () => { - const result = await protectClient.encryptQuery(42, { - column: users.age, - table: users, - queryType: 'equality', // age only has orderAndRange - }) - - expectFailure(result, 'unique') - expectFailure(result, 'not configured', ProtectErrorTypes.EncryptionError) - }, 30000) - }) - - describe('value/index type compatibility', () => { - it('fails when encrypting number with match index (explicit queryType)', async () => { - const result = await protectClient.encryptQuery(123, { - column: articles.content, // match-only column - table: articles, - queryType: 'freeTextSearch', - }) - - expectFailure(result, 'match') - expectFailure(result, 'numeric') - }, 30000) - - it('fails when encrypting number with auto-inferred match index', async () => { - const result = await protectClient.encryptQuery(123, { - column: articles.content, // match-only column, will infer 'match' - table: articles, - }) - - expectFailure(result, 'match') - }, 30000) - - it('fails in batch when number used with match index', async () => { - const result = await protectClient.encryptQuery([ - { value: 123, column: articles.content, table: articles }, - ]) - - expectFailure(result, 'match') - }, 30000) - - it('allows string with match index', async () => { - const result = await protectClient.encryptQuery('search text', { - column: articles.content, - table: articles, - }) - - const data = unwrapResult(result) - expect(data).toHaveProperty('bf') // bloom filter - }, 30000) - - it('allows number with ore index', async () => { - const result = await protectClient.encryptQuery(42, { - column: users.age, - table: users, - queryType: 'orderAndRange', - }) - - const data = unwrapResult(result) - expect(data).toHaveProperty('ob') // order bits - }, 30000) - }) - - describe('numeric edge cases', () => { - it('encrypts MAX_SAFE_INTEGER', async () => { - const result = await protectClient.encryptQuery(Number.MAX_SAFE_INTEGER, { - column: users.age, - table: users, - queryType: 'orderAndRange', - }) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'users', c: 'age' }, - v: 2, - }) - expect(data).toHaveProperty('ob') - }, 30000) - - it('encrypts MIN_SAFE_INTEGER', async () => { - const result = await protectClient.encryptQuery(Number.MIN_SAFE_INTEGER, { - column: users.age, - table: users, - queryType: 'orderAndRange', - }) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'users', c: 'age' }, - v: 2, - }) - expect(data).toHaveProperty('ob') - }, 30000) - - it('encrypts negative zero', async () => { - const result = await protectClient.encryptQuery(-0, { - column: users.age, - table: users, - queryType: 'orderAndRange', - }) - - const data = unwrapResult(result) - expect(data).toHaveProperty('ob') - }, 30000) - }) - - describe('string edge cases', () => { - it('encrypts empty string', async () => { - const result = await protectClient.encryptQuery('', { - column: users.email, - table: users, - queryType: 'equality', - }) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'users', c: 'email' }, - v: 2, - }) - expect(data).toHaveProperty('hm') - }, 30000) - - it('encrypts unicode/emoji strings', async () => { - const result = await protectClient.encryptQuery('Hello 世界 🌍🚀', { - column: users.bio, - table: users, - queryType: 'freeTextSearch', - }) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'users', c: 'bio' }, - v: 2, - }) - expect(data).toHaveProperty('bf') - }, 30000) - - it('encrypts strings with SQL special characters', async () => { - const result = await protectClient.encryptQuery( - "'; DROP TABLE users; --", - { - column: users.email, - table: users, - queryType: 'equality', - }, - ) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'users', c: 'email' }, - v: 2, - }) - expect(data).toHaveProperty('hm') - }, 30000) - }) - - describe('encryptQuery bulk (array overload)', () => { - it('encrypts multiple terms in batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: 'user@example.com', - column: users.email, - table: users, - queryType: 'equality', - }, - { - value: 'search term', - column: users.bio, - table: users, - queryType: 'freeTextSearch', - }, - { - value: 42, - column: users.age, - table: users, - queryType: 'orderAndRange', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(3) - expect(data[0]).toMatchObject({ i: { t: 'users', c: 'email' } }) - expect(data[1]).toMatchObject({ i: { t: 'users', c: 'bio' } }) - expect(data[2]).toMatchObject({ i: { t: 'users', c: 'age' } }) - }, 30000) - - it('handles empty array', async () => { - // Empty arrays without opts are treated as empty batch for backward compatibility - const result = await protectClient.encryptQuery([]) - - const data = unwrapResult(result) - expect(data).toEqual([]) - }, 30000) - - it('handles null values in batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: 'test@example.com', - column: users.email, - table: users, - queryType: 'equality', - }, - { - value: null, - column: users.bio, - table: users, - queryType: 'freeTextSearch', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(2) - expect(data[0]).not.toBeNull() - expect(data[1]).toBeNull() - }, 30000) - - it('auto-infers queryType when omitted', async () => { - const result = await protectClient.encryptQuery([ - { value: 'user@example.com', column: users.email, table: users }, - { value: 42, column: users.age, table: users }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(2) - expect(data[0]).toHaveProperty('hm') - expect(data[1]).toHaveProperty('ob') - }, 30000) - - it('rejects NaN/Infinity values in batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: Number.NaN, - column: users.age, - table: users, - queryType: 'orderAndRange', - }, - { - value: Number.POSITIVE_INFINITY, - column: users.age, - table: users, - queryType: 'orderAndRange', - }, - ]) - - expect(result.failure).toBeDefined() - }, 30000) - - it('rejects negative Infinity in batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: Number.NEGATIVE_INFINITY, - column: users.age, - table: users, - queryType: 'orderAndRange', - }, - ]) - - expectFailure(result, 'Infinity') - }, 30000) - }) - - describe('bulk index preservation', () => { - it('preserves exact positions with multiple nulls interspersed', async () => { - const result = await protectClient.encryptQuery([ - { - value: null, - column: users.email, - table: users, - queryType: 'equality', - }, - { - value: 'user@example.com', - column: users.email, - table: users, - queryType: 'equality', - }, - { - value: null, - column: users.bio, - table: users, - queryType: 'freeTextSearch', - }, - { - value: null, - column: users.age, - table: users, - queryType: 'orderAndRange', - }, - { - value: 42, - column: users.age, - table: users, - queryType: 'orderAndRange', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(5) - expect(data[0]).toBeNull() - expect(data[1]).not.toBeNull() - expect(data[1]).toHaveProperty('hm') - expect(data[2]).toBeNull() - expect(data[3]).toBeNull() - expect(data[4]).not.toBeNull() - expect(data[4]).toHaveProperty('ob') - }, 30000) - - it('handles single-item array', async () => { - const result = await protectClient.encryptQuery([ - { - value: 'single@example.com', - column: users.email, - table: users, - queryType: 'equality', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(1) - expect(data[0]).toMatchObject({ i: { t: 'users', c: 'email' } }) - expect(data[0]).toHaveProperty('hm') - }, 30000) - - it('handles all-null array', async () => { - const result = await protectClient.encryptQuery([ - { - value: null, - column: users.email, - table: users, - queryType: 'equality', - }, - { - value: null, - column: users.bio, - table: users, - queryType: 'freeTextSearch', - }, - { - value: null, - column: users.age, - table: users, - queryType: 'orderAndRange', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(3) - expect(data[0]).toBeNull() - expect(data[1]).toBeNull() - expect(data[2]).toBeNull() - }, 30000) - }) - - describe('audit support', () => { - it('passes audit metadata for single query', async () => { - const result = await protectClient - .encryptQuery('test@example.com', { - column: users.email, - table: users, - queryType: 'equality', - }) - .audit({ metadata: { userId: 'test-user' } }) - - const data = unwrapResult(result) - expect(data).toMatchObject({ i: { t: 'users', c: 'email' } }) - }, 30000) - - it('passes audit metadata for bulk query', async () => { - const result = await protectClient - .encryptQuery([ - { - value: 'test@example.com', - column: users.email, - table: users, - queryType: 'equality', - }, - ]) - .audit({ metadata: { userId: 'test-user' } }) - - const data = unwrapResult(result) - expect(data).toHaveLength(1) - }, 30000) - }) - - describe('returnType formatting', () => { - it('returns Encrypted by default (no returnType)', async () => { - const result = await protectClient.encryptQuery([ - { - value: 'test@example.com', - column: users.email, - table: users, - queryType: 'equality', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(1) - expect(data[0]).toMatchObject({ - i: { t: 'users', c: 'email' }, - v: 2, - }) - expect(typeof data[0]).toBe('object') - }, 30000) - - it('returns composite-literal format when specified', async () => { - const result = await protectClient.encryptQuery([ - { - value: 'test@example.com', - column: users.email, - table: users, - queryType: 'equality', - returnType: 'composite-literal', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(1) - expect(typeof data[0]).toBe('string') - // Format: ("json") - expect(data[0]).toMatch(/^\(".*"\)$/) - }, 30000) - - it('returns escaped-composite-literal format when specified', async () => { - const result = await protectClient.encryptQuery([ - { - value: 'test@example.com', - column: users.email, - table: users, - queryType: 'equality', - returnType: 'escaped-composite-literal', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(1) - expect(typeof data[0]).toBe('string') - // Format: "(\"json\")" - outer quotes with escaped inner quotes - expect(data[0]).toMatch(/^"\(.*\)"$/) - }, 30000) - - it('returns eql format when explicitly specified', async () => { - const result = await protectClient.encryptQuery([ - { - value: 'test@example.com', - column: users.email, - table: users, - queryType: 'equality', - returnType: 'eql', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(1) - expect(data[0]).toMatchObject({ - i: { t: 'users', c: 'email' }, - v: 2, - }) - expect(typeof data[0]).toBe('object') - }, 30000) - - it('handles mixed returnType values in same batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: 'test@example.com', - column: users.email, - table: users, - queryType: 'equality', - }, // default - { - value: 'search term', - column: users.bio, - table: users, - queryType: 'freeTextSearch', - returnType: 'composite-literal', - }, - { - value: 42, - column: users.age, - table: users, - queryType: 'orderAndRange', - returnType: 'escaped-composite-literal', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(3) - - // First: default (Encrypted object) - expect(typeof data[0]).toBe('object') - expect(data[0]).toMatchObject({ i: { t: 'users', c: 'email' } }) - - // Second: composite-literal (string) - expect(typeof data[1]).toBe('string') - expect(data[1]).toMatch(/^\(".*"\)$/) - - // Third: escaped-composite-literal (string) - expect(typeof data[2]).toBe('string') - expect(data[2]).toMatch(/^"\(.*\)"$/) - }, 30000) - - it('handles returnType with null values', async () => { - const result = await protectClient.encryptQuery([ - { - value: null, - column: users.email, - table: users, - queryType: 'equality', - returnType: 'composite-literal', - }, - { - value: 'test@example.com', - column: users.email, - table: users, - queryType: 'equality', - returnType: 'composite-literal', - }, - { - value: null, - column: users.bio, - table: users, - queryType: 'freeTextSearch', - returnType: 'escaped-composite-literal', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(3) - expect(data[0]).toBeNull() - expect(typeof data[1]).toBe('string') - expect(data[1]).toMatch(/^\(".*"\)$/) - expect(data[2]).toBeNull() - }, 30000) - }) - - describe('single-value returnType formatting', () => { - it('returns Encrypted by default (no returnType)', async () => { - const result = await protectClient.encryptQuery('test@example.com', { - column: users.email, - table: users, - queryType: 'equality', - }) - - const data = unwrapResult(result) - - expect(data).toMatchObject({ - i: { t: 'users', c: 'email' }, - v: 2, - }) - expect(typeof data).toBe('object') - }, 30000) - - it('returns composite-literal format when specified', async () => { - const result = await protectClient.encryptQuery('test@example.com', { - column: users.email, - table: users, - queryType: 'equality', - returnType: 'composite-literal', - }) - - const data = unwrapResult(result) - - expect(typeof data).toBe('string') - // Format: ("json") - expect(data).toMatch(/^\(".*"\)$/) - }, 30000) - - it('returns escaped-composite-literal format when specified', async () => { - const result = await protectClient.encryptQuery('test@example.com', { - column: users.email, - table: users, - queryType: 'equality', - returnType: 'escaped-composite-literal', - }) - - const data = unwrapResult(result) - - expect(typeof data).toBe('string') - // Format: "(\"json\")" - outer quotes with escaped inner quotes - expect(data).toMatch(/^"\(.*\)"$/) - }, 30000) - - it('returns eql format when explicitly specified', async () => { - const result = await protectClient.encryptQuery('test@example.com', { - column: users.email, - table: users, - queryType: 'equality', - returnType: 'eql', - }) - - const data = unwrapResult(result) - - expect(data).toMatchObject({ - i: { t: 'users', c: 'email' }, - v: 2, - }) - expect(typeof data).toBe('object') - }, 30000) - - it('handles null value with composite-literal returnType', async () => { - const result = await protectClient.encryptQuery(null, { - column: users.email, - table: users, - queryType: 'equality', - returnType: 'composite-literal', - }) - - const data = unwrapResult(result) - - expect(data).toBeNull() - }, 30000) - }) - - describe('LockContext support', () => { - it('single query with LockContext calls getLockContext', async () => { - const mockLockContext = createMockLockContext() - - const operation = protectClient.encryptQuery('test@example.com', { - column: users.email, - table: users, - queryType: 'equality', - }) - - const withContext = operation.withLockContext(mockLockContext as any) - expect(withContext).toHaveProperty('execute') - expect(typeof withContext.execute).toBe('function') - }, 30000) - - it('bulk query with LockContext calls getLockContext', async () => { - const mockLockContext = createMockLockContext() - - const operation = protectClient.encryptQuery([ - { - value: 'test@example.com', - column: users.email, - table: users, - queryType: 'equality', - }, - ]) - - const withContext = operation.withLockContext(mockLockContext as any) - expect(withContext).toHaveProperty('execute') - expect(typeof withContext.execute).toBe('function') - }, 30000) - - it('executes single query with LockContext mock', async () => { - const mockLockContext = createMockLockContext() - - const operation = protectClient.encryptQuery('test@example.com', { - column: users.email, - table: users, - queryType: 'equality', - }) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'users', c: 'email' }, - v: 2, - }) - expect(data).toHaveProperty('hm') - }, 30000) - - it('executes bulk query with LockContext mock', async () => { - const mockLockContext = createMockLockContext() - - const operation = protectClient.encryptQuery([ - { - value: 'test@example.com', - column: users.email, - table: users, - queryType: 'equality', - }, - { - value: 42, - column: users.age, - table: users, - queryType: 'orderAndRange', - }, - ]) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1) - - const data = unwrapResult(result) - expect(data).toHaveLength(2) - expect(data[0]).toHaveProperty('hm') - expect(data[1]).toHaveProperty('ob') - }, 30000) - - it('handles LockContext failure gracefully', async () => { - const mockLockContext = createFailingMockLockContext( - ProtectErrorTypes.CtsTokenError, - 'Mock LockContext failure', - ) - - const operation = protectClient.encryptQuery('test@example.com', { - column: users.email, - table: users, - queryType: 'equality', - }) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - expectFailure( - result, - 'Mock LockContext failure', - ProtectErrorTypes.CtsTokenError, - ) - }, 30000) - - it('handles null value with LockContext', async () => { - const mockLockContext = createMockLockContext() - - const operation = protectClient.encryptQuery(null, { - column: users.email, - table: users, - queryType: 'equality', - }) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - // Null values should return null without calling LockContext - // since there's nothing to encrypt - const data = unwrapResult(result) - expect(data).toBeNull() - }, 30000) - - it('handles explicit null context from getLockContext gracefully', async () => { - // Simulate a runtime scenario where context is null (bypasses TypeScript) - const mockLockContext = createMockLockContextWithNullContext() - - const operation = protectClient.encryptQuery([ - { - value: 'test@example.com', - column: users.email, - table: users, - queryType: 'equality', - }, - ]) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - // Should succeed - null context should not be passed to FFI - const data = unwrapResult(result) - expect(data).toHaveLength(1) - expect(data[0]).toHaveProperty('hm') - }, 30000) - }) -}) diff --git a/packages/protect/__tests__/error-codes.test.ts b/packages/protect/__tests__/error-codes.test.ts deleted file mode 100644 index d0a3d8596..000000000 --- a/packages/protect/__tests__/error-codes.test.ts +++ /dev/null @@ -1,369 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable } from '@cipherstash/schema' -import { beforeAll, describe, expect, it } from 'vitest' -import type { ProtectClient } from '../src' -import { FfiProtectError, ProtectErrorTypes, protect } from '../src' - -/** FFI tests require longer timeout due to client initialization */ -const FFI_TEST_TIMEOUT = 30_000 - -/** - * Tests for FFI error code preservation in ProtectError. - * These tests verify that specific FFI error codes are preserved when errors occur, - * enabling programmatic error handling. - */ -describe('FFI Error Code Preservation', () => { - let protectClient: ProtectClient - - // Schema with a valid column for testing - const testSchema = csTable('test_table', { - email: csColumn('email').equality(), - bio: csColumn('bio').freeTextSearch(), - age: csColumn('age').dataType('number').orderAndRange(), - metadata: csColumn('metadata').searchableJson(), - }) - - // Schema without indexes for testing non-FFI validation - const noIndexSchema = csTable('no_index_table', { - raw: csColumn('raw'), - }) - - // Schema with non-existent column for triggering FFI UNKNOWN_COLUMN error - const badModelSchema = csTable('test_table', { - nonexistent: csColumn('nonexistent_column'), - }) - - beforeAll(async () => { - protectClient = await protect({ schemas: [testSchema, noIndexSchema] }) - }) - - describe('FfiProtectError class', () => { - it('constructs with code and message', () => { - const error = new FfiProtectError({ - code: 'UNKNOWN_COLUMN', - message: 'Test error', - }) - expect(error.code).toBe('UNKNOWN_COLUMN') - expect(error.message).toBe('Test error') - }) - }) - - describe('encryptQuery error codes', () => { - it( - 'returns UNKNOWN_COLUMN code for non-existent column', - async () => { - // Create a fake column that doesn't exist in the schema - const fakeColumn = csColumn('nonexistent_column').equality() - - const result = await protectClient.encryptQuery('test', { - column: fakeColumn, - table: testSchema, - queryType: 'equality', - }) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError) - expect(result.failure?.code).toBe('UNKNOWN_COLUMN') - }, - FFI_TEST_TIMEOUT, - ) - - it( - 'returns undefined code for columns without indexes (non-FFI validation)', - async () => { - // This error is caught during pre-FFI validation, not by FFI itself - const result = await protectClient.encryptQuery('test', { - column: noIndexSchema.raw, - table: noIndexSchema, - }) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError) - expect(result.failure?.message).toContain('no indexes configured') - // Pre-FFI validation errors don't have FFI error codes - expect(result.failure?.code).toBeUndefined() - }, - FFI_TEST_TIMEOUT, - ) - - it( - 'returns undefined code for non-FFI validation errors', - async () => { - // NaN validation happens before FFI call - const result = await protectClient.encryptQuery(Number.NaN, { - column: testSchema.age, - table: testSchema, - queryType: 'orderAndRange', - }) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError) - // Non-FFI errors should have undefined code - expect(result.failure?.code).toBeUndefined() - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('batch encryptQuery error codes', () => { - it( - 'preserves error code in batch operations', - async () => { - const fakeColumn = csColumn('nonexistent_column').equality() - - const result = await protectClient.encryptQuery([ - { - value: 'test', - column: fakeColumn, - table: testSchema, - queryType: 'equality', - }, - ]) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError) - expect(result.failure?.code).toBe('UNKNOWN_COLUMN') - }, - FFI_TEST_TIMEOUT, - ) - - it( - 'returns undefined code for non-FFI batch errors', - async () => { - const result = await protectClient.encryptQuery([ - { - value: Number.NaN, - column: testSchema.age, - table: testSchema, - queryType: 'orderAndRange', - }, - ]) - - expect(result.failure).toBeDefined() - expect(result.failure?.code).toBeUndefined() - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('encrypt error codes', () => { - it( - 'returns UNKNOWN_COLUMN code for non-existent column in encrypt', - async () => { - const fakeColumn = csColumn('nonexistent_column') - - const result = await protectClient.encrypt('test', { - column: fakeColumn, - table: testSchema, - }) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError) - expect(result.failure?.code).toBe('UNKNOWN_COLUMN') - }, - FFI_TEST_TIMEOUT, - ) - - it( - 'returns undefined code for non-FFI encrypt errors', - async () => { - const result = await protectClient.encrypt(Number.NaN, { - column: testSchema.age, - table: testSchema, - }) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError) - // NaN validation happens before FFI call - expect(result.failure?.code).toBeUndefined() - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('decrypt error codes', () => { - it( - 'returns undefined code for malformed ciphertext (non-FFI validation)', - async () => { - // This error occurs during ciphertext parsing, not FFI decryption - const invalidCiphertext = { - i: { t: 'test_table', c: 'nonexistent' }, - v: 2, - c: 'invalid_ciphertext_data', - } - - const result = await protectClient.decrypt(invalidCiphertext) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.DecryptionError) - // Malformed ciphertext errors are caught before FFI and don't have codes - expect(result.failure?.code).toBeUndefined() - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('bulkEncrypt error codes', () => { - it( - 'returns UNKNOWN_COLUMN code for non-existent column', - async () => { - const fakeColumn = csColumn('nonexistent_column') - - const result = await protectClient.bulkEncrypt( - [{ plaintext: 'test1' }, { plaintext: 'test2' }], - { - column: fakeColumn, - table: testSchema, - }, - ) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError) - expect(result.failure?.code).toBe('UNKNOWN_COLUMN') - }, - FFI_TEST_TIMEOUT, - ) - - it( - 'returns undefined code for non-FFI validation errors', - async () => { - const result = await protectClient.bulkEncrypt( - [{ plaintext: Number.NaN }], - { - column: testSchema.age, - table: testSchema, - }, - ) - - expect(result.failure).toBeDefined() - expect(result.failure?.code).toBeUndefined() - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('bulkDecrypt error codes', () => { - it( - 'returns undefined code for malformed ciphertexts (non-FFI validation)', - async () => { - // bulkDecrypt uses the "fallible" FFI API (decryptBulkFallible) which normally: - // - Succeeds at the operation level - // - Returns per-item results with either { data } or { error } - // - // However, malformed ciphertexts cause parsing errors BEFORE the fallible API, - // which throws and triggers a top-level failure (not per-item errors). - // These pre-FFI errors don't have structured FFI error codes. - const invalidCiphertexts = [ - { data: { i: { t: 'test_table', c: 'email' }, v: 2, c: 'invalid1' } }, - { data: { i: { t: 'test_table', c: 'email' }, v: 2, c: 'invalid2' } }, - ] - - const result = await protectClient.bulkDecrypt(invalidCiphertexts) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.DecryptionError) - // FFI parsing errors don't have structured error codes - expect(result.failure?.code).toBeUndefined() - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('encryptModel error codes', () => { - it( - 'returns UNKNOWN_COLUMN code for model with non-existent column', - async () => { - const model = { nonexistent: 'test value' } - - const result = await protectClient.encryptModel(model, badModelSchema) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError) - expect(result.failure?.code).toBe('UNKNOWN_COLUMN') - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('decryptModel error codes', () => { - it( - 'returns undefined code for malformed model (non-FFI validation)', - async () => { - const malformedModel = { - email: { - i: { t: 'test_table', c: 'email' }, - v: 2, - c: 'invalid_ciphertext', - }, - } - - const result = await protectClient.decryptModel(malformedModel) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.DecryptionError) - expect(result.failure?.code).toBeUndefined() - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('bulkEncryptModels error codes', () => { - it( - 'returns UNKNOWN_COLUMN code for models with non-existent column', - async () => { - const models = [{ nonexistent: 'value1' }, { nonexistent: 'value2' }] - - const result = await protectClient.bulkEncryptModels( - models, - badModelSchema, - ) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError) - expect(result.failure?.code).toBe('UNKNOWN_COLUMN') - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('bulkDecryptModels error codes', () => { - it( - 'returns undefined code for malformed models (non-FFI validation)', - async () => { - const malformedModels = [ - { - email: { i: { t: 'test_table', c: 'email' }, v: 2, c: 'invalid1' }, - }, - { - email: { i: { t: 'test_table', c: 'email' }, v: 2, c: 'invalid2' }, - }, - ] - - const result = await protectClient.bulkDecryptModels(malformedModels) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.DecryptionError) - expect(result.failure?.code).toBeUndefined() - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('searchTerms (deprecated) error codes', () => { - it( - 'returns UNKNOWN_COLUMN code for non-existent column', - async () => { - const fakeColumn = csColumn('nonexistent_column').equality() - - const result = await protectClient.createSearchTerms([ - { value: 'test', column: fakeColumn, table: testSchema }, - ]) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError) - expect(result.failure?.code).toBe('UNKNOWN_COLUMN') - }, - FFI_TEST_TIMEOUT, - ) - }) -}) diff --git a/packages/protect/__tests__/fixtures/index.ts b/packages/protect/__tests__/fixtures/index.ts deleted file mode 100644 index fa892b375..000000000 --- a/packages/protect/__tests__/fixtures/index.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { csColumn, csTable } from '@cipherstash/schema' -import { expect, vi } from 'vitest' - -// ============ Schema Fixtures ============ - -/** - * Users table with multiple index types for testing - */ -export const users = csTable('users', { - email: csColumn('email').equality(), - bio: csColumn('bio').freeTextSearch(), - age: csColumn('age').dataType('number').orderAndRange(), -}) - -/** - * Articles table with only freeTextSearch (for auto-inference test) - */ -export const articles = csTable('articles', { - content: csColumn('content').freeTextSearch(), -}) - -/** - * Products table with only orderAndRange (for auto-inference test) - */ -export const products = csTable('products', { - price: csColumn('price').dataType('number').orderAndRange(), -}) - -/** - * Metadata table with no indexes (for validation error test) - */ -export const metadata = csTable('metadata', { - raw: csColumn('raw'), -}) - -/** - * Documents table with searchable JSON column (for STE Vec queries) - */ -export const jsonbSchema = csTable('documents', { - id: csColumn('id'), - metadata: csColumn('metadata').searchableJson(), -}) - -/** - * Schema fixture with mixed column types including JSON. - */ -export const mixedSchema = csTable('records', { - id: csColumn('id'), - email: csColumn('email').equality(), - name: csColumn('name').freeTextSearch(), - metadata: csColumn('metadata').searchableJson(), -}) - -// ============ Mock Factories ============ - -/** - * Creates a mock LockContext with successful response - */ -export function createMockLockContext(overrides?: { - accessToken?: string - expiry?: number - identityClaim?: string[] -}) { - return { - getLockContext: vi.fn().mockResolvedValue({ - data: { - ctsToken: { - accessToken: overrides?.accessToken ?? 'mock-token', - expiry: overrides?.expiry ?? Date.now() + 3600000, - }, - context: { - identityClaim: overrides?.identityClaim ?? ['sub'], - }, - }, - }), - } -} - -/** - * Creates a mock LockContext with explicit null context (simulates runtime edge case) - */ -export function createMockLockContextWithNullContext() { - return { - getLockContext: vi.fn().mockResolvedValue({ - data: { - ctsToken: { - accessToken: 'mock-token', - expiry: Date.now() + 3600000, - }, - context: null, // Explicit null - simulating runtime edge case - }, - }), - } -} - -/** - * Creates a mock LockContext that returns a failure - */ -export function createFailingMockLockContext( - errorType: string, - message: string, -) { - return { - getLockContext: vi.fn().mockResolvedValue({ - failure: { type: errorType, message }, - }), - } -} - -// ============ Test Helpers ============ - -/** - * Unwraps a Result type, throwing an error if it's a failure. - * Use this to simplify test assertions when you expect success. - */ -export function unwrapResult(result: { - data?: T - failure?: { message: string } -}): T { - if (result.failure) { - throw new Error(result.failure.message) - } - return result.data as T -} - -/** - * Asserts that a result is a failure with optional message and type matching - */ -export function expectFailure( - result: { failure?: { message: string; type?: string } }, - messagePattern?: string | RegExp, - expectedType?: string, -) { - expect(result.failure).toBeDefined() - if (messagePattern) { - if (typeof messagePattern === 'string') { - expect(result.failure?.message).toContain(messagePattern) - } else { - expect(result.failure?.message).toMatch(messagePattern) - } - } - if (expectedType) { - expect(result.failure?.type).toBe(expectedType) - } -} diff --git a/packages/protect/__tests__/helpers.test.ts b/packages/protect/__tests__/helpers.test.ts deleted file mode 100644 index 566ff2554..000000000 --- a/packages/protect/__tests__/helpers.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - bulkModelsToEncryptedPgComposites, - encryptedToCompositeLiteral, - encryptedToPgComposite, - isEncryptedPayload, - isEncryptedScalarQuery, - modelToEncryptedPgComposites, -} from '../src/helpers' - -describe('helpers', () => { - describe('encryptedToPgComposite', () => { - it('should convert encrypted payload to pg composite', () => { - const encrypted = { - v: 1, - c: 'ciphertext', - i: { - c: 'iv', - t: 't', - }, - k: 'k', - ob: ['a', 'b'], - bf: [1, 2, 3], - hm: 'hm', - } - - const pgComposite = encryptedToPgComposite(encrypted) - expect(pgComposite).toEqual({ - data: encrypted, - }) - }) - }) - - describe('encryptedToCompositeLiteral', () => { - it('should convert encrypted payload to pg composite literal string', () => { - const encrypted = { - v: 1, - c: 'ciphertext', - i: { - c: 'iv', - t: 't', - }, - } - - const literal = encryptedToCompositeLiteral(encrypted) - // Should produce PostgreSQL composite literal format: ("json_string") - expect(literal).toMatch(/^\(.*\)$/) - // The inner content should be a valid JSON string (double-stringified) - const innerContent = literal.slice(1, -1) // Remove outer parentheses - expect(() => JSON.parse(innerContent)).not.toThrow() - // Parsing the inner content should give us the original JSON - const parsedJson = JSON.parse(JSON.parse(innerContent)) - expect(parsedJson).toEqual(encrypted) - }) - }) - - describe('isEncryptedPayload', () => { - it('should return true for valid encrypted payload', () => { - const encrypted = { - v: 1, - c: 'ciphertext', - i: { c: 'iv', t: 't' }, - } - expect(isEncryptedPayload(encrypted)).toBe(true) - }) - - it('should return false for null', () => { - expect(isEncryptedPayload(null)).toBe(false) - }) - - it('should return false for non-encrypted object', () => { - expect(isEncryptedPayload({ foo: 'bar' })).toBe(false) - }) - }) - - describe('isEncryptedScalarQuery', () => { - const baseIndex = { c: 'email', t: 'users' } - - it('returns true for a unique (hm) scalar query term', () => { - expect( - isEncryptedScalarQuery({ - v: 2, - k: 'ct', - i: baseIndex, - hm: 'abc123', - }), - ).toBe(true) - }) - - it('returns true for a match (bf) scalar query term', () => { - expect( - isEncryptedScalarQuery({ - v: 2, - k: 'ct', - i: baseIndex, - bf: [1, 2, 3], - }), - ).toBe(true) - }) - - it('returns true for an ore (ob) scalar query term', () => { - expect( - isEncryptedScalarQuery({ - v: 2, - k: 'ct', - i: baseIndex, - ob: ['a', 'b'], - }), - ).toBe(true) - }) - - it('returns false when the storage ciphertext (c) is present', () => { - expect( - isEncryptedScalarQuery({ - v: 2, - k: 'ct', - i: baseIndex, - c: 'ciphertext', - hm: 'abc123', - }), - ).toBe(false) - }) - - it('returns false when more than one lookup term is present', () => { - expect( - isEncryptedScalarQuery({ - v: 2, - k: 'ct', - i: baseIndex, - hm: 'abc123', - bf: [1, 2, 3], - }), - ).toBe(false) - }) - - it('returns false when no lookup term is present', () => { - expect( - isEncryptedScalarQuery({ - v: 2, - k: 'ct', - i: baseIndex, - }), - ).toBe(false) - }) - - it('returns false for a ste_vec query term (k: "sv")', () => { - expect( - isEncryptedScalarQuery({ - v: 2, - k: 'sv', - i: baseIndex, - sv: [{ s: 'selector', t: 'term' }], - }), - ).toBe(false) - }) - - it('returns false when version (v) is missing or not a number', () => { - expect( - isEncryptedScalarQuery({ - k: 'ct', - i: baseIndex, - hm: 'abc123', - }), - ).toBe(false) - expect( - isEncryptedScalarQuery({ - v: '2', - k: 'ct', - i: baseIndex, - hm: 'abc123', - }), - ).toBe(false) - }) - - it('returns false when index (i) is missing or null', () => { - expect( - isEncryptedScalarQuery({ - v: 2, - k: 'ct', - hm: 'abc123', - }), - ).toBe(false) - expect( - isEncryptedScalarQuery({ - v: 2, - k: 'ct', - i: null, - hm: 'abc123', - }), - ).toBe(false) - }) - - it('returns false for null, primitives, and non-objects', () => { - expect(isEncryptedScalarQuery(null)).toBe(false) - expect(isEncryptedScalarQuery(undefined)).toBe(false) - expect(isEncryptedScalarQuery('string')).toBe(false) - expect(isEncryptedScalarQuery(42)).toBe(false) - }) - }) - - describe('modelToEncryptedPgComposites', () => { - it('should transform model with encrypted fields', () => { - const model = { - name: 'John', - email: { - v: 1, - c: 'encrypted_email', - i: { c: 'iv', t: 't' }, - }, - age: 30, - } - - const result = modelToEncryptedPgComposites(model) - expect(result).toEqual({ - name: 'John', - email: { - data: { - v: 1, - c: 'encrypted_email', - i: { c: 'iv', t: 't' }, - }, - }, - age: 30, - }) - }) - }) - - describe('bulkModelsToEncryptedPgComposites', () => { - it('should transform multiple models with encrypted fields', () => { - const models = [ - { - name: 'John', - email: { - v: 1, - c: 'encrypted_email1', - i: { c: 'iv', t: 't' }, - }, - }, - { - name: 'Jane', - email: { - v: 1, - c: 'encrypted_email2', - i: { c: 'iv', t: 't' }, - }, - }, - ] - - const result = bulkModelsToEncryptedPgComposites(models) - expect(result).toEqual([ - { - name: 'John', - email: { - data: { - v: 1, - c: 'encrypted_email1', - i: { c: 'iv', t: 't' }, - }, - }, - }, - { - name: 'Jane', - email: { - data: { - v: 1, - c: 'encrypted_email2', - i: { c: 'iv', t: 't' }, - }, - }, - }, - ]) - }) - }) -}) diff --git a/packages/protect/__tests__/infer-index-type.test.ts b/packages/protect/__tests__/infer-index-type.test.ts deleted file mode 100644 index ac9c9340f..000000000 --- a/packages/protect/__tests__/infer-index-type.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { csColumn, csTable } from '@cipherstash/schema' -import { describe, expect, it } from 'vitest' -import { inferQueryOpFromPlaintext } from '../src/ffi/helpers/infer-index-type' -import { inferIndexType, validateIndexType } from '../src/index' - -describe('infer-index-type helpers', () => { - const users = csTable('users', { - email: csColumn('email').equality(), - bio: csColumn('bio').freeTextSearch(), - age: csColumn('age').orderAndRange(), - name: csColumn('name').equality().freeTextSearch(), - }) - - describe('inferIndexType', () => { - it('returns unique for equality-only column', () => { - expect(inferIndexType(users.email)).toBe('unique') - }) - - it('returns match for freeTextSearch-only column', () => { - expect(inferIndexType(users.bio)).toBe('match') - }) - - it('returns ore for orderAndRange-only column', () => { - expect(inferIndexType(users.age)).toBe('ore') - }) - - it('returns unique when multiple indexes (priority: unique > match > ore)', () => { - expect(inferIndexType(users.name)).toBe('unique') - }) - - it('returns match when freeTextSearch and orderAndRange (priority: match > ore)', () => { - const schema = csTable('t', { - col: csColumn('col').freeTextSearch().orderAndRange(), - }) - expect(inferIndexType(schema.col)).toBe('match') - }) - - it('throws for column with no indexes', () => { - const noIndex = csTable('t', { col: csColumn('col') }) - expect(() => inferIndexType(noIndex.col)).toThrow('no indexes configured') - }) - - it('returns ste_vec for searchableJson-only column', () => { - const schema = csTable('t', { col: csColumn('col').searchableJson() }) - expect(inferIndexType(schema.col)).toBe('ste_vec') - }) - }) - - describe('validateIndexType', () => { - it('does not throw for valid index type', () => { - expect(() => validateIndexType(users.email, 'unique')).not.toThrow() - }) - - it('throws for unconfigured index type', () => { - expect(() => validateIndexType(users.email, 'match')).toThrow( - 'not configured', - ) - }) - - it('accepts ste_vec when configured', () => { - const schema = csTable('t', { col: csColumn('col').searchableJson() }) - expect(() => validateIndexType(schema.col, 'ste_vec')).not.toThrow() - }) - - it('rejects ste_vec when not configured', () => { - const schema = csTable('t', { col: csColumn('col').equality() }) - expect(() => validateIndexType(schema.col, 'ste_vec')).toThrow( - 'not configured', - ) - }) - }) - - describe('inferQueryOpFromPlaintext', () => { - it('returns ste_vec_selector for string plaintext', () => { - expect(inferQueryOpFromPlaintext('$.user.email')).toBe('ste_vec_selector') - }) - - it('returns ste_vec_term for object plaintext', () => { - expect(inferQueryOpFromPlaintext({ role: 'admin' })).toBe('ste_vec_term') - }) - - it('returns ste_vec_term for array plaintext', () => { - expect(inferQueryOpFromPlaintext(['admin', 'user'])).toBe('ste_vec_term') - }) - - it('returns ste_vec_term for number plaintext', () => { - expect(inferQueryOpFromPlaintext(42)).toBe('ste_vec_term') - }) - - it('returns ste_vec_term for boolean plaintext', () => { - expect(inferQueryOpFromPlaintext(true)).toBe('ste_vec_term') - }) - }) -}) diff --git a/packages/protect/__tests__/json-protect.test.ts b/packages/protect/__tests__/json-protect.test.ts deleted file mode 100644 index 22fc203ec..000000000 --- a/packages/protect/__tests__/json-protect.test.ts +++ /dev/null @@ -1,1217 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable, csValue } from '@cipherstash/schema' -import { beforeAll, describe, expect, it } from 'vitest' -import { LockContext, protect } from '../src' - -const users = csTable('users', { - email: csColumn('email').freeTextSearch().equality().orderAndRange(), - address: csColumn('address').freeTextSearch(), - json: csColumn('json').dataType('json'), - metadata: { - profile: csValue('metadata.profile').dataType('json'), - settings: { - preferences: csValue('metadata.settings.preferences').dataType('json'), - }, - }, -}) - -type User = { - id: string - email?: string | null - createdAt?: Date - updatedAt?: Date - address?: string | null - json?: Record | null - metadata?: { - profile?: Record | null - settings?: { - preferences?: Record | null - } - } -} - -let protectClient: Awaited> - -beforeAll(async () => { - protectClient = await protect({ - schemas: [users], - }) -}) - -describe('JSON encryption and decryption', () => { - it('should encrypt and decrypt a simple JSON payload', async () => { - const json = { - name: 'John Doe', - age: 30, - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) - - it('should encrypt and decrypt a complex JSON payload', async () => { - const json = { - user: { - id: 123, - name: 'Jane Smith', - email: 'jane@example.com', - preferences: { - theme: 'dark', - notifications: true, - language: 'en-US', - }, - tags: ['premium', 'verified'], - metadata: { - created: '2023-01-01T00:00:00Z', - lastLogin: '2023-12-01T10:30:00Z', - }, - }, - settings: { - privacy: { - public: false, - shareData: true, - }, - features: { - beta: true, - experimental: false, - }, - }, - array: [1, 2, 3, { nested: 'value' }], - nullValue: null, - booleanValue: true, - numberValue: 42.5, - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) - - it('should handle null JSON payload', async () => { - const ciphertext = await protectClient.encrypt(null, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify null is preserved - expect(ciphertext.data).toBeNull() - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: null, - }) - }, 30000) - - it('should handle empty JSON object', async () => { - const json = {} - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) - - it('should handle JSON with special characters', async () => { - const json = { - message: 'Hello "world" with \'quotes\' and \\backslashes\\', - unicode: '🚀 emoji and ñ special chars', - symbols: '!@#$%^&*()_+-=[]{}|;:,.<>?/~`', - multiline: 'Line 1\nLine 2\tTabbed', - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) -}) - -describe('JSON model encryption and decryption', () => { - it('should encrypt and decrypt a model with JSON field', async () => { - const decryptedModel = { - id: '1', - email: 'test@example.com', - address: '123 Main St', - json: { - name: 'John Doe', - age: 30, - preferences: { - theme: 'dark', - notifications: true, - }, - }, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('k', 'ct') - expect(encryptedModel.data.address).toHaveProperty('k', 'ct') - expect(encryptedModel.data.json).toHaveProperty('k', 'ct') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle null JSON in model', async () => { - const decryptedModel = { - id: '2', - email: 'test2@example.com', - address: '456 Oak St', - json: null, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('k', 'ct') - expect(encryptedModel.data.address).toHaveProperty('k', 'ct') - expect(encryptedModel.data.json).toBeNull() - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle undefined JSON in model', async () => { - const decryptedModel = { - id: '3', - email: 'test3@example.com', - address: '789 Pine St', - json: undefined, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('k', 'ct') - expect(encryptedModel.data.address).toHaveProperty('k', 'ct') - expect(encryptedModel.data.json).toBeUndefined() - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) -}) - -describe('JSON bulk encryption and decryption', () => { - it('should bulk encrypt and decrypt JSON payloads', async () => { - const jsonPayloads = [ - { id: 'user1', plaintext: { name: 'Alice', age: 25 } }, - { id: 'user2', plaintext: { name: 'Bob', age: 30 } }, - { id: 'user3', plaintext: { name: 'Charlie', age: 35 } }, - ] - - const encryptedData = await protectClient.bulkEncrypt(jsonPayloads, { - column: users.json, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(3) - expect(encryptedData.data[0]).toHaveProperty('id', 'user1') - expect(encryptedData.data[0]).toHaveProperty('data') - expect(encryptedData.data[0].data).toHaveProperty('k', 'ct') - expect(encryptedData.data[1]).toHaveProperty('id', 'user2') - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[1].data).toHaveProperty('k', 'ct') - expect(encryptedData.data[2]).toHaveProperty('id', 'user3') - expect(encryptedData.data[2]).toHaveProperty('data') - expect(encryptedData.data[2].data).toHaveProperty('k', 'ct') - - // Now decrypt the data - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify decrypted data - expect(decryptedData.data).toHaveLength(3) - expect(decryptedData.data[0]).toHaveProperty('id', 'user1') - expect(decryptedData.data[0]).toHaveProperty('data', { - name: 'Alice', - age: 25, - }) - expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', { - name: 'Bob', - age: 30, - }) - expect(decryptedData.data[2]).toHaveProperty('id', 'user3') - expect(decryptedData.data[2]).toHaveProperty('data', { - name: 'Charlie', - age: 35, - }) - }, 30000) - - it('should handle mixed null and non-null JSON in bulk operations', async () => { - const jsonPayloads = [ - { id: 'user1', plaintext: { name: 'Alice', age: 25 } }, - { id: 'user2', plaintext: null }, - { id: 'user3', plaintext: { name: 'Charlie', age: 35 } }, - ] - - const encryptedData = await protectClient.bulkEncrypt(jsonPayloads, { - column: users.json, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(3) - expect(encryptedData.data[0]).toHaveProperty('id', 'user1') - expect(encryptedData.data[0]).toHaveProperty('data') - expect(encryptedData.data[0].data).toHaveProperty('k', 'ct') - expect(encryptedData.data[1]).toHaveProperty('id', 'user2') - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[1].data).toBeNull() - expect(encryptedData.data[2]).toHaveProperty('id', 'user3') - expect(encryptedData.data[2]).toHaveProperty('data') - expect(encryptedData.data[2].data).toHaveProperty('k', 'ct') - - // Now decrypt the data - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify decrypted data - expect(decryptedData.data).toHaveLength(3) - expect(decryptedData.data[0]).toHaveProperty('id', 'user1') - expect(decryptedData.data[0]).toHaveProperty('data', { - name: 'Alice', - age: 25, - }) - expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', null) - expect(decryptedData.data[2]).toHaveProperty('id', 'user3') - expect(decryptedData.data[2]).toHaveProperty('data', { - name: 'Charlie', - age: 35, - }) - }, 30000) - - it('should bulk encrypt and decrypt models with JSON fields', async () => { - const decryptedModels = [ - { - id: '1', - email: 'test1@example.com', - address: '123 Main St', - json: { - name: 'Alice', - preferences: { theme: 'dark' }, - }, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - }, - { - id: '2', - email: 'test2@example.com', - address: '456 Oak St', - json: { - name: 'Bob', - preferences: { theme: 'light' }, - }, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - }, - ] - - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields for each model - expect(encryptedModels.data[0].email).toHaveProperty('k', 'ct') - expect(encryptedModels.data[0].address).toHaveProperty('k', 'ct') - expect(encryptedModels.data[0].json).toHaveProperty('k', 'ct') - expect(encryptedModels.data[1].email).toHaveProperty('k', 'ct') - expect(encryptedModels.data[1].address).toHaveProperty('k', 'ct') - expect(encryptedModels.data[1].json).toHaveProperty('k', 'ct') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[0].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].id).toBe('2') - expect(encryptedModels.data[1].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].updatedAt).toEqual(new Date('2021-01-01')) - - const decryptedResult = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModels) - }, 30000) -}) - -describe('JSON encryption with lock context', () => { - it('should encrypt and decrypt JSON with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - const json = { - name: 'John Doe', - age: 30, - preferences: { - theme: 'dark', - notifications: true, - }, - } - - const ciphertext = await protectClient - .encrypt(json, { - column: users.json, - table: users, - }) - .withLockContext(lockContext.data) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient - .decrypt(ciphertext.data) - .withLockContext(lockContext.data) - - if (plaintext.failure) { - throw new Error(`[protect]: ${plaintext.failure.message}`) - } - - expect(plaintext.data).toEqual(json) - }, 30000) - - it('should encrypt JSON model with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - const decryptedModel = { - id: '1', - email: 'test@example.com', - json: { - name: 'John Doe', - preferences: { theme: 'dark' }, - }, - } - - const encryptedModel = await protectClient - .encryptModel(decryptedModel, users) - .withLockContext(lockContext.data) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('k', 'ct') - expect(encryptedModel.data.json).toHaveProperty('k', 'ct') - - const decryptedResult = await protectClient - .decryptModel(encryptedModel.data) - .withLockContext(lockContext.data) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should bulk encrypt JSON with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - const jsonPayloads = [ - { id: 'user1', plaintext: { name: 'Alice', age: 25 } }, - { id: 'user2', plaintext: { name: 'Bob', age: 30 } }, - ] - - const encryptedData = await protectClient - .bulkEncrypt(jsonPayloads, { - column: users.json, - table: users, - }) - .withLockContext(lockContext.data) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(2) - expect(encryptedData.data[0]).toHaveProperty('id', 'user1') - expect(encryptedData.data[0]).toHaveProperty('data') - expect(encryptedData.data[0].data).toHaveProperty('k', 'ct') - expect(encryptedData.data[1]).toHaveProperty('id', 'user2') - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[1].data).toHaveProperty('k', 'ct') - - // Decrypt with lock context - const decryptedData = await protectClient - .bulkDecrypt(encryptedData.data) - .withLockContext(lockContext.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify decrypted data - expect(decryptedData.data).toHaveLength(2) - expect(decryptedData.data[0]).toHaveProperty('id', 'user1') - expect(decryptedData.data[0]).toHaveProperty('data', { - name: 'Alice', - age: 25, - }) - expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', { - name: 'Bob', - age: 30, - }) - }, 30000) -}) - -describe('JSON nested object encryption', () => { - it('should encrypt and decrypt nested JSON objects', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '1', - email: 'test@example.com', - metadata: { - profile: { - name: 'John Doe', - age: 30, - preferences: { - theme: 'dark', - notifications: true, - }, - }, - settings: { - preferences: { - language: 'en-US', - timezone: 'UTC', - }, - }, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('k', 'ct') - expect(encryptedModel.data.metadata?.profile).toHaveProperty('k', 'ct') - expect(encryptedModel.data.metadata?.settings?.preferences).toHaveProperty( - 'c', - ) - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle null values in nested JSON objects', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '2', - email: 'test2@example.com', - metadata: { - profile: null, - settings: { - preferences: null, - }, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify null fields are preserved - expect(encryptedModel.data.email).toHaveProperty('k', 'ct') - expect(encryptedModel.data.metadata?.profile).toBeNull() - expect(encryptedModel.data.metadata?.settings?.preferences).toBeNull() - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle undefined values in nested JSON objects', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '3', - email: 'test3@example.com', - metadata: { - profile: undefined, - settings: { - preferences: undefined, - }, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify undefined fields are preserved - expect(encryptedModel.data.email).toHaveProperty('k', 'ct') - expect(encryptedModel.data.metadata?.profile).toBeUndefined() - expect(encryptedModel.data.metadata?.settings?.preferences).toBeUndefined() - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) -}) - -describe('JSON edge cases and error handling', () => { - it('should handle very large JSON objects', async () => { - const largeJson = { - data: Array.from({ length: 1000 }, (_, i) => ({ - id: i, - name: `User ${i}`, - email: `user${i}@example.com`, - metadata: { - preferences: { - theme: i % 2 === 0 ? 'dark' : 'light', - notifications: i % 3 === 0, - }, - }, - })), - metadata: { - total: 1000, - created: new Date().toISOString(), - }, - } - - const ciphertext = await protectClient.encrypt(largeJson, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: largeJson, - }) - }, 30000) - - it('should handle JSON with circular references (should fail gracefully)', async () => { - const circularObj: Record = { name: 'test' } - circularObj.self = circularObj - - try { - await protectClient.encrypt(circularObj, { - column: users.json, - table: users, - }) - // This should not reach here as JSON.stringify should fail - expect(true).toBe(false) - } catch (error) { - // Expected to fail due to circular reference - expect(error).toBeDefined() - } - }, 30000) - - it('should handle JSON with special data types', async () => { - const json = { - string: 'hello', - number: 42, - boolean: true, - null: null, - array: [1, 2, 3], - object: { nested: 'value' }, - date: new Date('2023-01-01T00:00:00Z'), - // Note: Functions and undefined are not JSON serializable - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - // Date objects get serialized to strings in JSON - const expectedJson = { - ...json, - date: '2023-01-01T00:00:00.000Z', - } - - expect(plaintext).toEqual({ - data: expectedJson, - }) - }, 30000) -}) - -describe('JSON performance tests', () => { - it('should handle large numbers of JSON objects efficiently', async () => { - const largeJsonArray = Array.from({ length: 100 }, (_, i) => ({ - id: i, - data: { - name: `User ${i}`, - preferences: { - theme: i % 2 === 0 ? 'dark' : 'light', - notifications: i % 3 === 0, - }, - metadata: { - created: new Date().toISOString(), - version: i, - }, - }, - })) - - const jsonPayloads = largeJsonArray.map((item, index) => ({ - id: `user${index}`, - plaintext: item, - })) - - const encryptedData = await protectClient.bulkEncrypt(jsonPayloads, { - column: users.json, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(100) - - // Decrypt the data - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify all data is preserved - expect(decryptedData.data).toHaveLength(100) - - for (let i = 0; i < 100; i++) { - expect(decryptedData.data[i].id).toBe(`user${i}`) - expect(decryptedData.data[i].data).toEqual(largeJsonArray[i]) - } - }, 5000) -}) - -describe('JSON advanced scenarios', () => { - it('should handle JSON with deeply nested arrays', async () => { - const json = { - users: [ - { - id: 1, - name: 'Alice', - roles: [ - { name: 'admin', permissions: ['read', 'write', 'delete'] }, - { name: 'user', permissions: ['read'] }, - ], - }, - { - id: 2, - name: 'Bob', - roles: [{ name: 'user', permissions: ['read'] }], - }, - ], - metadata: { - total: 2, - lastUpdated: new Date().toISOString(), - }, - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) - - it('should handle JSON with mixed data types in arrays', async () => { - const json = { - mixedArray: ['string', 42, true, null, { nested: 'object' }, [1, 2, 3]], - metadata: { - types: ['string', 'number', 'boolean', 'null', 'object', 'array'], - }, - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) - - it('should handle JSON with Unicode and international characters', async () => { - const json = { - international: { - chinese: '你好世界', - japanese: 'こんにちは世界', - korean: '안녕하세요 세계', - arabic: 'مرحبا بالعالم', - russian: 'Привет мир', - emoji: '🚀 🌍 💻 🎉', - }, - metadata: { - languages: ['Chinese', 'Japanese', 'Korean', 'Arabic', 'Russian'], - encoding: 'UTF-8', - }, - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) - - it('should handle JSON with scientific notation and large numbers', async () => { - const json = { - numbers: { - integer: 1234567890, - float: Math.PI, - scientific: 1.23e10, - negative: -9876543210, - zero: 0, - verySmall: 1.23e-10, - }, - metadata: { - precision: 'high', - format: 'scientific', - }, - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) - - it('should handle JSON with boolean edge cases', async () => { - const json = { - booleans: { - true: true, - false: false, - stringTrue: 'true', - stringFalse: 'false', - numberOne: 1, - numberZero: 0, - emptyString: '', - nullValue: null, - }, - metadata: { - type: 'boolean_edge_cases', - }, - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) -}) - -describe('JSON error handling and edge cases', () => { - it('should handle malformed JSON gracefully', async () => { - // This test ensures the library handles JSON serialization errors - const invalidJson = { - valid: 'data', - // This will cause JSON.stringify to fail - circular: null as unknown, - } - - // Create a circular reference - invalidJson.circular = invalidJson - - try { - await protectClient.encrypt(invalidJson, { - column: users.json, - table: users, - }) - expect(true).toBe(false) // Should not reach here - } catch (error) { - expect(error).toBeDefined() - expect(error).toBeInstanceOf(Error) - } - }, 30000) - - it('should handle empty arrays and objects', async () => { - const json = { - emptyArray: [], - emptyObject: {}, - nestedEmpty: { - array: [], - object: {}, - }, - mixedEmpty: { - data: 'present', - empty: [], - null: null, - }, - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) - - it('should handle JSON with very long strings', async () => { - const longString = 'A'.repeat(10000) // 10KB string - const json = { - longString, - metadata: { - length: longString.length, - type: 'long_string', - }, - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) - - it('should handle JSON with all primitive types', async () => { - const json = { - string: 'hello world', - number: 42, - float: 3.14, - boolean: true, - null: null, - array: [1, 2, 3], - object: { key: 'value' }, - nested: { - level1: { - level2: { - level3: 'deep value', - }, - }, - }, - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) -}) diff --git a/packages/protect/__tests__/jsonb-helpers.test.ts b/packages/protect/__tests__/jsonb-helpers.test.ts deleted file mode 100644 index 9f12d131f..000000000 --- a/packages/protect/__tests__/jsonb-helpers.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { buildNestedObject, parseJsonbPath, toJsonPath } from '../src' - -describe('toJsonPath', () => { - it('converts simple path to JSONPath format', () => { - expect(toJsonPath('name')).toBe('$.name') - }) - - it('converts nested path to JSONPath format', () => { - expect(toJsonPath('user.email')).toBe('$.user.email') - }) - - it('converts deeply nested path', () => { - expect(toJsonPath('user.profile.settings.theme')).toBe( - '$.user.profile.settings.theme', - ) - }) - - it('returns unchanged if already in JSONPath format', () => { - expect(toJsonPath('$.user.email')).toBe('$.user.email') - }) - - it('normalizes bare $ prefix', () => { - expect(toJsonPath('$user.email')).toBe('$.user.email') - }) - - it('handles path starting with dot', () => { - expect(toJsonPath('.user.email')).toBe('$.user.email') - }) - - it('handles root path', () => { - expect(toJsonPath('$')).toBe('$') - }) - - it('handles empty string', () => { - expect(toJsonPath('')).toBe('$') - }) - - it('handles array index in path', () => { - expect(toJsonPath('user.roles[0]')).toBe('$.user.roles[0]') - }) - - it('handles array index with nested property', () => { - expect(toJsonPath('items[0].name')).toBe('$.items[0].name') - }) - - it('handles already-prefixed path with array index', () => { - expect(toJsonPath('$.data[2]')).toBe('$.data[2]') - }) - - it('handles nested array indices', () => { - expect(toJsonPath('matrix[0][1]')).toBe('$.matrix[0][1]') - }) - - it('handles array index at root level', () => { - expect(toJsonPath('[0].name')).toBe('$[0].name') - }) - - it('preserves already-prefixed root array index', () => { - expect(toJsonPath('$[0]')).toBe('$[0]') - }) - - it('preserves already-prefixed root array index with property', () => { - expect(toJsonPath('$[0].name')).toBe('$[0].name') - }) - - it('handles large array index', () => { - expect(toJsonPath('items[999].value')).toBe('$.items[999].value') - }) - - it('handles deeply nested path after array index', () => { - expect(toJsonPath('data[0].user.profile.settings')).toBe( - '$.data[0].user.profile.settings', - ) - }) - - it('handles root array with nested array', () => { - expect(toJsonPath('[0].items[1].name')).toBe('$[0].items[1].name') - }) -}) - -describe('buildNestedObject', () => { - it('builds single-level object', () => { - expect(buildNestedObject('name', 'alice')).toEqual({ name: 'alice' }) - }) - - it('builds two-level nested object', () => { - expect(buildNestedObject('user.role', 'admin')).toEqual({ - user: { role: 'admin' }, - }) - }) - - it('builds deeply nested object', () => { - expect(buildNestedObject('a.b.c.d', 'value')).toEqual({ - a: { b: { c: { d: 'value' } } }, - }) - }) - - it('handles numeric values', () => { - expect(buildNestedObject('user.age', 30)).toEqual({ - user: { age: 30 }, - }) - }) - - it('handles boolean values', () => { - expect(buildNestedObject('user.active', true)).toEqual({ - user: { active: true }, - }) - }) - - it('handles null values', () => { - expect(buildNestedObject('user.data', null)).toEqual({ - user: { data: null }, - }) - }) - - it('handles object values', () => { - const value = { nested: 'object' } - expect(buildNestedObject('user.config', value)).toEqual({ - user: { config: { nested: 'object' } }, - }) - }) - - it('handles array values', () => { - expect(buildNestedObject('user.tags', ['admin', 'user'])).toEqual({ - user: { tags: ['admin', 'user'] }, - }) - }) - - it('strips JSONPath prefix from path', () => { - expect(buildNestedObject('$.user.role', 'admin')).toEqual({ - user: { role: 'admin' }, - }) - }) - - it('throws on empty path', () => { - expect(() => buildNestedObject('', 'value')).toThrow('Path cannot be empty') - }) - - it('throws on root-only path', () => { - expect(() => buildNestedObject('$', 'value')).toThrow( - 'Path must contain at least one segment', - ) - }) - - it('throws on __proto__ segment', () => { - expect(() => buildNestedObject('__proto__.polluted', 'yes')).toThrow( - 'Path contains forbidden segment: __proto__', - ) - }) - - it('throws on prototype segment', () => { - expect(() => buildNestedObject('user.prototype.hack', 'yes')).toThrow( - 'Path contains forbidden segment: prototype', - ) - }) - - it('throws on constructor segment', () => { - expect(() => buildNestedObject('constructor', 'yes')).toThrow( - 'Path contains forbidden segment: constructor', - ) - }) - - it('throws on nested forbidden segment', () => { - expect(() => buildNestedObject('a.b.__proto__', 'yes')).toThrow( - 'Path contains forbidden segment: __proto__', - ) - }) -}) - -describe('parseJsonbPath', () => { - it('parses simple path', () => { - expect(parseJsonbPath('name')).toEqual(['name']) - }) - - it('parses nested path', () => { - expect(parseJsonbPath('user.email')).toEqual(['user', 'email']) - }) - - it('parses deeply nested path', () => { - expect(parseJsonbPath('a.b.c.d')).toEqual(['a', 'b', 'c', 'd']) - }) - - it('strips JSONPath prefix', () => { - expect(parseJsonbPath('$.user.email')).toEqual(['user', 'email']) - }) - - it('strips bare $ prefix', () => { - expect(parseJsonbPath('$user.email')).toEqual(['user', 'email']) - }) - - it('handles empty string', () => { - expect(parseJsonbPath('')).toEqual([]) - }) - - it('handles root only', () => { - expect(parseJsonbPath('$')).toEqual([]) - }) - - it('filters empty segments', () => { - expect(parseJsonbPath('user..email')).toEqual(['user', 'email']) - }) -}) diff --git a/packages/protect/__tests__/k-discriminator.test.ts b/packages/protect/__tests__/k-discriminator.test.ts deleted file mode 100644 index 002ff842f..000000000 --- a/packages/protect/__tests__/k-discriminator.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable } from '@cipherstash/schema' -import { beforeAll, describe, expect, it } from 'vitest' -import { protect } from '../src' - -const users = csTable('users', { - email: csColumn('email'), -}) - -describe('k-field discriminator (EQL v2.3)', () => { - let protectClient: Awaited> - - beforeAll(async () => { - protectClient = await protect({ schemas: [users] }) - }) - - it('encrypts scalar data with k: "ct" discriminator', async () => { - const testData = 'test@example.com' - - const result = await protectClient.encrypt(testData, { - column: users.email, - table: users, - }) - - if (result.failure) { - throw new Error(`Encryption failed: ${result.failure.message}`) - } - - expect(result.data).toHaveProperty('k', 'ct') - expect(result.data).toHaveProperty('c') - expect(result.data).toHaveProperty('v') - expect(result.data).toHaveProperty('i') - }, 30000) - - it('decrypts a payload round-trips back to the original plaintext', async () => { - const testData = 'roundtrip@example.com' - - const encrypted = await protectClient.encrypt(testData, { - column: users.email, - table: users, - }) - - if (encrypted.failure) { - throw new Error(`Encryption failed: ${encrypted.failure.message}`) - } - - const result = await protectClient.decrypt(encrypted.data!) - - if (result.failure) { - throw new Error(`Decryption failed: ${result.failure.message}`) - } - - expect(result.data).toBe(testData) - }, 30000) -}) diff --git a/packages/protect/__tests__/keysets.test.ts b/packages/protect/__tests__/keysets.test.ts deleted file mode 100644 index e8f4e0fed..000000000 --- a/packages/protect/__tests__/keysets.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import 'dotenv/config' -import { ensureKeyset } from '@cipherstash/protect-ffi' -import { csColumn, csTable } from '@cipherstash/schema' -import { beforeAll, describe, expect, it } from 'vitest' -import { protect } from '../src' - -const users = csTable('users', { - email: csColumn('email'), -}) - -let testKeysetId: string - -beforeAll(async () => { - const keyset = await ensureKeyset({ name: 'Test' }) - testKeysetId = keyset.id -}) - -describe('encryption and decryption with keyset id', () => { - it('should encrypt and decrypt a payload', async () => { - const protectClient = await protect({ - schemas: [users], - keyset: { - id: testKeysetId, - }, - }) - - const email = 'hello@example.com' - - const ciphertext = await protectClient.encrypt(email, { - column: users.email, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const a = ciphertext.data - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: email, - }) - }, 30000) -}) - -describe('encryption and decryption with keyset name', () => { - it('should encrypt and decrypt a payload', async () => { - const protectClient = await protect({ - schemas: [users], - keyset: { - name: 'Test', - }, - }) - - const email = 'hello@example.com' - - const ciphertext = await protectClient.encrypt(email, { - column: users.email, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const a = ciphertext.data - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: email, - }) - }, 30000) -}) - -describe('encryption and decryption with invalid keyset id', () => { - it('should throw an error', async () => { - await expect( - protect({ - schemas: [users], - keyset: { - id: 'invalid-uuid', - }, - }), - ).rejects.toThrow( - '[protect]: Invalid UUID provided for keyset id. Must be a valid UUID.', - ) - }) -}) diff --git a/packages/protect/__tests__/lock-context.test.ts b/packages/protect/__tests__/lock-context.test.ts deleted file mode 100644 index 4a0894b29..000000000 --- a/packages/protect/__tests__/lock-context.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable } from '@cipherstash/schema' -import { beforeAll, describe, expect, it } from 'vitest' -import { protect } from '../src' -import { LockContext } from '../src/identify' - -const users = csTable('users', { - email: csColumn('email').freeTextSearch().equality().orderAndRange(), - address: csColumn('address').freeTextSearch(), -}) - -type User = { - id: string - email?: string | null - createdAt?: Date - updatedAt?: Date - address?: string | null - number?: number -} - -let protectClient: Awaited> - -beforeAll(async () => { - protectClient = await protect({ - schemas: [users], - }) -}) - -describe('encryption and decryption with lock context', () => { - it('should encrypt and decrypt a payload with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - const email = 'hello@example.com' - - const ciphertext = await protectClient - .encrypt(email, { - column: users.email, - table: users, - }) - .withLockContext(lockContext.data) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - const plaintext = await protectClient - .decrypt(ciphertext.data) - .withLockContext(lockContext.data) - - if (plaintext.failure) { - throw new Error(`[protect]: ${plaintext.failure.message}`) - } - - expect(plaintext.data).toEqual(email) - }, 30000) - - it('should encrypt and decrypt a model with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - // Create a model with decrypted values - const decryptedModel = { - id: '1', - email: 'plaintext', - } - - // Encrypt the model with lock context - const encryptedModel = await protectClient - .encryptModel(decryptedModel, users) - .withLockContext(lockContext.data) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Decrypt the model with lock context - const decryptedResult = await protectClient - .decryptModel(encryptedModel.data) - .withLockContext(lockContext.data) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual({ - id: '1', - email: 'plaintext', - }) - }, 30000) - - it('should encrypt with context and be unable to decrypt without context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - // Create a model with decrypted values - const decryptedModel = { - id: '1', - email: 'plaintext', - } - - // Encrypt the model with lock context - const encryptedModel = await protectClient - .encryptModel(decryptedModel, users) - .withLockContext(lockContext.data) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - try { - await protectClient.decryptModel(encryptedModel.data) - } catch (error) { - const e = error as Error - expect(e.message.startsWith('Failed to retrieve key')).toEqual(true) - } - }, 30000) - - it('should bulk encrypt and decrypt models with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - // Create models with decrypted values - const decryptedModels = [ - { - id: '1', - email: 'test', - }, - { - id: '2', - email: 'test2', - }, - ] - - // Encrypt the models with lock context - const encryptedModels = await protectClient - .bulkEncryptModels(decryptedModels, users) - .withLockContext(lockContext.data) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Decrypt the models with lock context - const decryptedResult = await protectClient - .bulkDecryptModels(encryptedModels.data) - .withLockContext(lockContext.data) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual([ - { - id: '1', - email: 'test', - }, - { - id: '2', - email: 'test2', - }, - ]) - }, 30000) -}) diff --git a/packages/protect/__tests__/nested-models.test.ts b/packages/protect/__tests__/nested-models.test.ts deleted file mode 100644 index 8f44f809b..000000000 --- a/packages/protect/__tests__/nested-models.test.ts +++ /dev/null @@ -1,958 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable, csValue } from '@cipherstash/schema' -import { describe, expect, it, vi } from 'vitest' -import { LockContext, protect } from '../src' - -const users = csTable('users', { - email: csColumn('email').freeTextSearch().equality().orderAndRange(), - address: csColumn('address').freeTextSearch(), - name: csColumn('name').freeTextSearch(), - example: { - field: csValue('example.field'), - nested: { - deeper: csValue('example.nested.deeper'), - }, - }, -}) - -type User = { - id: string - email?: string | null - createdAt?: Date - updatedAt?: Date - address?: string | null - notEncrypted?: string | null - example: { - field: string | undefined | null - nested?: { - deeper: string | undefined | null - plaintext?: string | undefined | null - notInSchema?: { - deeper: string | undefined | null - } - deeperNotInSchema?: string | undefined | null - extra?: { - plaintext: string | undefined | null - } - } - plaintext?: string | undefined | null - fieldNotInSchema?: string | undefined | null - notInSchema?: { - deeper: string | undefined | null - } - } -} - -describe('encrypt models with nested fields', () => { - it('should encrypt and decrypt a single value from a nested schema', async () => { - const protectClient = await protect({ schemas: [users] }) - - const encryptResponse = await protectClient.encrypt('hello world', { - column: users.example.field, - table: users, - }) - - if (encryptResponse.failure) { - throw new Error(`[protect]: ${encryptResponse.failure.message}`) - } - - // Verify encrypted field - expect(encryptResponse.data).toHaveProperty('c') - - const decryptResponse = await protectClient.decrypt(encryptResponse.data) - - if (decryptResponse.failure) { - throw new Error(`[protect]: ${decryptResponse.failure.message}`) - } - - expect(decryptResponse).toEqual({ - data: 'hello world', - }) - }) - - it('should encrypt and decrypt a model with nested fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '1', - email: 'test@example.com', - address: '123 Main St', - notEncrypted: 'not encrypted', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - example: { - field: 'test', - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.address).toHaveProperty('c') - expect(encryptedModel.data.example.field).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.notEncrypted).toBe('not encrypted') - expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle null values in nested fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '2', - email: null, - address: null, - example: { - field: null, - nested: { - deeper: null, - }, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify null fields are preserved - expect(encryptedModel.data.email).toBeNull() - expect(encryptedModel.data.address).toBeNull() - expect(encryptedModel.data.example.field).toBeNull() - expect(encryptedModel.data.example.nested?.deeper).toBeNull() - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle undefined values in nested fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '3', - example: { - field: undefined, - nested: { - deeper: undefined, - }, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify undefined fields are preserved - expect(encryptedModel.data.email).toBeUndefined() - expect(encryptedModel.data.example.field).toBeUndefined() - expect(encryptedModel.data.example.nested?.deeper).toBeUndefined() - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle mixed null and undefined values in nested fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '4', - email: 'test@example.com', - address: undefined, - notEncrypted: 'not encrypted', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - example: { - field: null, - nested: { - deeper: undefined, - }, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - - // Verify null/undefined fields are preserved - expect(encryptedModel.data.address).toBeUndefined() - expect(encryptedModel.data.example.field).toBeNull() - expect(encryptedModel.data.example.nested?.deeper).toBeUndefined() - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('4') - expect(encryptedModel.data.notEncrypted).toBe('not encrypted') - expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle deeply nested fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '3', - example: { - field: 'outer', - nested: { - deeper: 'inner value', - }, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.example.field).toHaveProperty('c') - expect(encryptedModel.data.example.nested?.deeper).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('3') - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle missing optional nested fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '5', - example: { - field: 'present', - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.example.field).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('5') - expect(encryptedModel.data.example.nested).toBeUndefined() - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - describe('bulk operations with nested fields', () => { - it('should handle bulk encryption and decryption of models with nested fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModels: User[] = [ - { - id: '1', - email: 'test1@example.com', - example: { - field: 'test1', - nested: { - deeper: 'value1', - }, - }, - }, - { - id: '2', - email: 'test2@example.com', - example: { - field: 'test2', - nested: { - deeper: 'value2', - }, - }, - }, - ] - - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields for each model - expect(encryptedModels.data[0].email).toHaveProperty('c') - expect(encryptedModels.data[0].example.field).toHaveProperty('c') - expect(encryptedModels.data[0].example.nested?.deeper).toHaveProperty('c') - expect(encryptedModels.data[1].email).toHaveProperty('c') - expect(encryptedModels.data[1].example.field).toHaveProperty('c') - expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[1].id).toBe('2') - - const decryptedResults = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResults.failure) { - throw new Error(`[protect]: ${decryptedResults.failure.message}`) - } - - expect(decryptedResults.data).toEqual(decryptedModels) - }, 30000) - - it('should handle bulk operations with null and undefined values in nested fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModels: User[] = [ - { - id: '1', - email: null, - example: { - field: null, - nested: { - deeper: undefined, - }, - }, - }, - { - id: '2', - email: undefined, - example: { - field: undefined, - nested: { - deeper: null, - }, - }, - }, - ] - - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify null/undefined fields are preserved - expect(encryptedModels.data[0].email).toBeNull() - expect(encryptedModels.data[0].example.field).toBeNull() - expect(encryptedModels.data[0].example.nested?.deeper).toBeUndefined() - expect(encryptedModels.data[1].email).toBeUndefined() - expect(encryptedModels.data[1].example.field).toBeUndefined() - expect(encryptedModels.data[1].example.nested?.deeper).toBeNull() - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[1].id).toBe('2') - - const decryptedResults = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResults.failure) { - throw new Error(`[protect]: ${decryptedResults.failure.message}`) - } - - expect(decryptedResults.data).toEqual(decryptedModels) - }, 30000) - - it('should handle bulk operations with missing optional nested fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModels: User[] = [ - { - id: '1', - email: 'test1@example.com', - example: { - field: 'test1', - }, - }, - { - id: '2', - email: 'test2@example.com', - example: { - field: 'test2', - nested: { - deeper: 'value2', - }, - }, - }, - ] - - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields for each model - expect(encryptedModels.data[0].email).toHaveProperty('c') - expect(encryptedModels.data[0].example.field).toHaveProperty('c') - expect(encryptedModels.data[1].email).toHaveProperty('c') - expect(encryptedModels.data[1].example.field).toHaveProperty('c') - expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[0].example.nested).toBeUndefined() - expect(encryptedModels.data[1].id).toBe('2') - - const decryptedResults = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResults.failure) { - throw new Error(`[protect]: ${decryptedResults.failure.message}`) - } - - expect(decryptedResults.data).toEqual(decryptedModels) - }, 30000) - - it('should handle empty array in bulk operations', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModels: User[] = [] - - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - expect(encryptedModels.data).toEqual([]) - - const decryptedResults = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResults.failure) { - throw new Error(`[protect]: ${decryptedResults.failure.message}`) - } - - expect(decryptedResults.data).toEqual([]) - }, 30000) - }) -}) - -describe('nested fields with a plaintext field', () => { - it('should handle nested fields with a plaintext field', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '1', - email: 'test@example.com', - address: '123 Main St', - notEncrypted: 'not encrypted', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - example: { - field: 'test', - plaintext: 'plaintext', - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.address).toHaveProperty('c') - expect(encryptedModel.data.example.field).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.notEncrypted).toBe('not encrypted') - expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.example.plaintext).toBe('plaintext') - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }) - - it('should handle multiple plaintext fields at different nesting levels', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '1', - email: 'test@example.com', - address: '123 Main St', - notEncrypted: 'not encrypted', - example: { - field: 'encrypted field', - plaintext: 'top level plaintext', - nested: { - deeper: 'encrypted deeper', - plaintext: 'nested plaintext', - extra: { - plaintext: 'deeply nested plaintext', - }, - }, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.address).toHaveProperty('c') - expect(encryptedModel.data.example.field).toHaveProperty('c') - expect(encryptedModel.data.example.nested?.deeper).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.notEncrypted).toBe('not encrypted') - expect(encryptedModel.data.example.plaintext).toBe('top level plaintext') - expect(encryptedModel.data.example.nested?.plaintext).toBe( - 'nested plaintext', - ) - expect(encryptedModel.data.example.nested?.extra?.plaintext).toBe( - 'deeply nested plaintext', - ) - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }) - - it('should handle partial path matches in nested objects', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '1', - email: 'test@example.com', - example: { - field: 'encrypted field', - nested: { - deeper: 'encrypted deeper', - // This should not be encrypted as it's not in the schema - notInSchema: { - deeper: 'not encrypted', - }, - }, - // This should not be encrypted as it's not in the schema - notInSchema: { - deeper: 'not encrypted', - }, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.example.field).toHaveProperty('c') - expect(encryptedModel.data.example.nested?.deeper).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.example.nested?.notInSchema?.deeper).toBe( - 'not encrypted', - ) - expect(encryptedModel.data.example.notInSchema?.deeper).toBe( - 'not encrypted', - ) - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }) - - it('should handle mixed encrypted and plaintext fields with similar paths', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '1', - email: 'test@example.com', - example: { - field: 'encrypted field', - fieldNotInSchema: 'not encrypted', - nested: { - deeper: 'encrypted deeper', - deeperNotInSchema: 'not encrypted', - }, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.example.field).toHaveProperty('c') - expect(encryptedModel.data.example.nested?.deeper).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.example.fieldNotInSchema).toBe('not encrypted') - expect(encryptedModel.data.example.nested?.deeperNotInSchema).toBe( - 'not encrypted', - ) - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }) - - describe('bulk operations with plaintext fields', () => { - it('should handle bulk encryption and decryption with plaintext fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModels: User[] = [ - { - id: '1', - email: 'test1@example.com', - address: '123 Main St', - example: { - field: 'encrypted field 1', - plaintext: 'plaintext 1', - nested: { - deeper: 'encrypted deeper 1', - plaintext: 'nested plaintext 1', - }, - }, - }, - { - id: '2', - email: 'test2@example.com', - address: '456 Main St', - example: { - field: 'encrypted field 2', - plaintext: 'plaintext 2', - nested: { - deeper: 'encrypted deeper 2', - plaintext: 'nested plaintext 2', - }, - }, - }, - ] - - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModels.data[0].email).toHaveProperty('c') - expect(encryptedModels.data[0].address).toHaveProperty('c') - expect(encryptedModels.data[0].example.field).toHaveProperty('c') - expect(encryptedModels.data[0].example.nested?.deeper).toHaveProperty('c') - expect(encryptedModels.data[1].email).toHaveProperty('c') - expect(encryptedModels.data[1].address).toHaveProperty('c') - expect(encryptedModels.data[1].example.field).toHaveProperty('c') - expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[0].example.plaintext).toBe('plaintext 1') - expect(encryptedModels.data[0].example.nested?.plaintext).toBe( - 'nested plaintext 1', - ) - expect(encryptedModels.data[1].id).toBe('2') - expect(encryptedModels.data[1].example.plaintext).toBe('plaintext 2') - expect(encryptedModels.data[1].example.nested?.plaintext).toBe( - 'nested plaintext 2', - ) - - const decryptedResults = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResults.failure) { - throw new Error(`[protect]: ${decryptedResults.failure.message}`) - } - - expect(decryptedResults.data).toEqual(decryptedModels) - }) - - it('should handle bulk operations with mixed encrypted and non-encrypted fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModels: User[] = [ - { - id: '1', - email: 'test1@example.com', - example: { - field: 'encrypted field 1', - fieldNotInSchema: 'not encrypted 1', - nested: { - deeper: 'encrypted deeper 1', - deeperNotInSchema: 'not encrypted deeper 1', - }, - }, - }, - { - id: '2', - email: 'test2@example.com', - example: { - field: 'encrypted field 2', - fieldNotInSchema: 'not encrypted 2', - nested: { - deeper: 'encrypted deeper 2', - deeperNotInSchema: 'not encrypted deeper 2', - }, - }, - }, - ] - - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModels.data[0].email).toHaveProperty('c') - expect(encryptedModels.data[0].example.field).toHaveProperty('c') - expect(encryptedModels.data[0].example.nested?.deeper).toHaveProperty('c') - expect(encryptedModels.data[1].email).toHaveProperty('c') - expect(encryptedModels.data[1].example.field).toHaveProperty('c') - expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[0].example.fieldNotInSchema).toBe( - 'not encrypted 1', - ) - expect(encryptedModels.data[0].example.nested?.deeperNotInSchema).toBe( - 'not encrypted deeper 1', - ) - expect(encryptedModels.data[1].id).toBe('2') - expect(encryptedModels.data[1].example.fieldNotInSchema).toBe( - 'not encrypted 2', - ) - expect(encryptedModels.data[1].example.nested?.deeperNotInSchema).toBe( - 'not encrypted deeper 2', - ) - - const decryptedResults = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResults.failure) { - throw new Error(`[protect]: ${decryptedResults.failure.message}`) - } - - expect(decryptedResults.data).toEqual(decryptedModels) - }) - - it('should handle bulk operations with deeply nested plaintext fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModels: User[] = [ - { - id: '1', - email: 'test1@example.com', - example: { - field: 'encrypted field 1', - nested: { - deeper: 'encrypted deeper 1', - extra: { - plaintext: 'deeply nested plaintext 1', - }, - }, - }, - }, - { - id: '2', - email: 'test2@example.com', - example: { - field: 'encrypted field 2', - nested: { - deeper: 'encrypted deeper 2', - extra: { - plaintext: 'deeply nested plaintext 2', - }, - }, - }, - }, - ] - - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModels.data[0].email).toHaveProperty('c') - expect(encryptedModels.data[0].example.field).toHaveProperty('c') - expect(encryptedModels.data[0].example.nested?.deeper).toHaveProperty('c') - expect(encryptedModels.data[1].email).toHaveProperty('c') - expect(encryptedModels.data[1].example.field).toHaveProperty('c') - expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[0].example.nested?.extra?.plaintext).toBe( - 'deeply nested plaintext 1', - ) - expect(encryptedModels.data[1].id).toBe('2') - expect(encryptedModels.data[1].example.nested?.extra?.plaintext).toBe( - 'deeply nested plaintext 2', - ) - - const decryptedResults = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResults.failure) { - throw new Error(`[protect]: ${decryptedResults.failure.message}`) - } - - expect(decryptedResults.data).toEqual(decryptedModels) - }) - }) -}) diff --git a/packages/protect/__tests__/number-protect.test.ts b/packages/protect/__tests__/number-protect.test.ts deleted file mode 100644 index 754e42e6d..000000000 --- a/packages/protect/__tests__/number-protect.test.ts +++ /dev/null @@ -1,823 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable, csValue } from '@cipherstash/schema' -import { beforeAll, describe, expect, it, test } from 'vitest' -import { LockContext, protect } from '../src' - -const users = csTable('users', { - email: csColumn('email').freeTextSearch().equality().orderAndRange(), - address: csColumn('address').freeTextSearch(), - age: csColumn('age').dataType('number').equality().orderAndRange(), - score: csColumn('score').dataType('number').equality().orderAndRange(), - metadata: { - count: csValue('metadata.count').dataType('number'), - level: csValue('metadata.level').dataType('number'), - }, -}) - -type User = { - id: string - email?: string - createdAt?: Date - updatedAt?: Date - address?: string - age?: number - score?: number - metadata?: { - count?: number - level?: number - } -} - -let protectClient: Awaited> - -beforeAll(async () => { - protectClient = await protect({ - schemas: [users], - }) -}) - -const cases = [ - 25, - 0, - -42, - 2147483647, - 77.9, - 0.0, - -117.123456, - 1e15, - -1e15, // Very large floats - 9007199254740991, // Max safe integer in JavaScript -] - -describe('Number encryption and decryption', () => { - test.each(cases)('should encrypt and decrypt a number: %d', async (age) => { - const ciphertext = await protectClient.encrypt(age, { - column: users.age, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: age, - }) - }, 30000) - - it('should handle null integer', async () => { - const ciphertext = await protectClient.encrypt(null, { - column: users.age, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify null is preserved - expect(ciphertext.data).toBeNull() - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: null, - }) - }, 30000) - - // Special case - it('should treat a negative zero valued float as 0.0', async () => { - const score = -0.0 - - const ciphertext = await protectClient.encrypt(score, { - column: users.score, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: 0.0, - }) - }, 30000) - - // Special case - it('should error for a NaN float', async () => { - const score = Number.NaN - - const result = await protectClient.encrypt(score, { - column: users.score, - table: users, - }) - - expect(result.failure).toBeDefined() - expect(result.failure?.message).toContain('Cannot encrypt NaN value') - }, 30000) - - // Special case - it('should error for Infinity', async () => { - const score = Number.POSITIVE_INFINITY - - const result = await protectClient.encrypt(score, { - column: users.score, - table: users, - }) - - expect(result.failure).toBeDefined() - expect(result.failure?.message).toContain('Cannot encrypt Infinity value') - }, 30000) - - // Special case - it('should error for -Infinity', async () => { - const score = Number.NEGATIVE_INFINITY - - const result = await protectClient.encrypt(score, { - column: users.score, - table: users, - }) - - expect(result.failure).toBeDefined() - expect(result.failure?.message).toContain('Cannot encrypt Infinity value') - }, 30000) -}) - -describe('Model encryption and decryption', () => { - it('should encrypt and decrypt a model with number fields', async () => { - const decryptedModel = { - id: '1', - email: 'test@example.com', - address: '123 Main St', - age: 30, - score: 95, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.address).toHaveProperty('c') - expect(encryptedModel.data.age).toHaveProperty('c') - expect(encryptedModel.data.score).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle null numbers in model', async () => { - const decryptedModel: User = { - id: '2', - email: 'test2@example.com', - address: '456 Oak St', - age: undefined, - score: undefined, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.address).toHaveProperty('c') - expect(encryptedModel.data.age).toBeUndefined() - expect(encryptedModel.data.score).toBeUndefined() - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle undefined numbers in model', async () => { - const decryptedModel = { - id: '3', - email: 'test3@example.com', - address: '789 Pine St', - age: undefined, - score: undefined, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.address).toHaveProperty('c') - expect(encryptedModel.data.age).toBeUndefined() - expect(encryptedModel.data.score).toBeUndefined() - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) -}) - -describe('Bulk encryption and decryption', () => { - it('should bulk encrypt and decrypt number payloads', async () => { - const intPayloads = [ - { id: 'user1', plaintext: 25 }, - { id: 'user2', plaintext: 30.7 }, - { id: 'user3', plaintext: -35.123 }, - ] - - const encryptedData = await protectClient.bulkEncrypt(intPayloads, { - column: users.age, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(3) - expect(encryptedData.data[0]).toHaveProperty('id', 'user1') - expect(encryptedData.data[0]).toHaveProperty('data') - expect(encryptedData.data[0].data).toHaveProperty('c') - expect(encryptedData.data[1]).toHaveProperty('id', 'user2') - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[1].data).toHaveProperty('c') - expect(encryptedData.data[2]).toHaveProperty('id', 'user3') - expect(encryptedData.data[2]).toHaveProperty('data') - expect(encryptedData.data[2].data).toHaveProperty('c') - - // EQL v2.3: scalar encryptions carry the `k: 'ct'` discriminator - expect(encryptedData.data[0].data).toHaveProperty('k', 'ct') - expect(encryptedData.data[1].data).toHaveProperty('k', 'ct') - expect(encryptedData.data[2].data).toHaveProperty('k', 'ct') - - // Verify all encrypted values are different - const getCiphertext = (data: { c?: unknown } | null | undefined) => data?.c - - expect(getCiphertext(encryptedData.data[0].data)).not.toBe( - getCiphertext(encryptedData.data[1].data), - ) - expect(getCiphertext(encryptedData.data[1].data)).not.toBe( - getCiphertext(encryptedData.data[2].data), - ) - expect(getCiphertext(encryptedData.data[0].data)).not.toBe( - getCiphertext(encryptedData.data[2].data), - ) - - // Now decrypt the data - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify decrypted data - expect(decryptedData.data).toHaveLength(3) - expect(decryptedData.data[0]).toHaveProperty('id', 'user1') - expect(decryptedData.data[0]).toHaveProperty('data', 25) - expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', 30.7) - expect(decryptedData.data[2]).toHaveProperty('id', 'user3') - expect(decryptedData.data[2]).toHaveProperty('data', -35.123) - }, 30000) - - it('should handle mixed null and non-null numbers in bulk operations', async () => { - const intPayloads = [ - { id: 'user1', plaintext: 25 }, - { id: 'user2', plaintext: null }, - { id: 'user3', plaintext: 35 }, - ] - - const encryptedData = await protectClient.bulkEncrypt(intPayloads, { - column: users.age, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(3) - expect(encryptedData.data[0]).toHaveProperty('id', 'user1') - expect(encryptedData.data[0]).toHaveProperty('data') - expect(encryptedData.data[0].data).toHaveProperty('c') - expect(encryptedData.data[1]).toHaveProperty('id', 'user2') - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[1].data).toBeNull() - expect(encryptedData.data[2]).toHaveProperty('id', 'user3') - expect(encryptedData.data[2]).toHaveProperty('data') - expect(encryptedData.data[2].data).toHaveProperty('c') - - // Now decrypt the data - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify decrypted data - expect(decryptedData.data).toHaveLength(3) - expect(decryptedData.data[0]).toHaveProperty('id', 'user1') - expect(decryptedData.data[0]).toHaveProperty('data', 25) - expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', null) - expect(decryptedData.data[2]).toHaveProperty('id', 'user3') - expect(decryptedData.data[2]).toHaveProperty('data', 35) - }, 30000) - - it('should bulk encrypt and decrypt models with number fields', async () => { - const decryptedModels = [ - { - id: '1', - email: 'test1@example.com', - address: '123 Main St', - age: 25, - score: 85, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - }, - { - id: '2', - email: 'test2@example.com', - address: '456 Oak St', - age: 30, - score: 90, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - }, - ] - - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields for each model - expect(encryptedModels.data[0].email).toHaveProperty('c') - expect(encryptedModels.data[0].address).toHaveProperty('c') - expect(encryptedModels.data[0].age).toHaveProperty('c') - expect(encryptedModels.data[0].score).toHaveProperty('c') - expect(encryptedModels.data[1].email).toHaveProperty('c') - expect(encryptedModels.data[1].address).toHaveProperty('c') - expect(encryptedModels.data[1].age).toHaveProperty('c') - expect(encryptedModels.data[1].score).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[0].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].id).toBe('2') - expect(encryptedModels.data[1].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].updatedAt).toEqual(new Date('2021-01-01')) - - const decryptedResult = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModels) - }, 30000) -}) - -describe('Encryption with lock context', () => { - it('should encrypt and decrypt number with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - const age = 42 - - const ciphertext = await protectClient - .encrypt(age, { - column: users.age, - table: users, - }) - .withLockContext(lockContext.data) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const plaintext = await protectClient - .decrypt(ciphertext.data) - .withLockContext(lockContext.data) - - if (plaintext.failure) { - throw new Error(`[protect]: ${plaintext.failure.message}`) - } - - expect(plaintext.data).toEqual(age) - }, 30000) - - it('should encrypt model with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - const decryptedModel = { - id: '1', - email: 'test@example.com', - age: 30, - score: 95, - } - - const encryptedModel = await protectClient - .encryptModel(decryptedModel, users) - .withLockContext(lockContext.data) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.age).toHaveProperty('c') - expect(encryptedModel.data.score).toHaveProperty('c') - - const decryptedResult = await protectClient - .decryptModel(encryptedModel.data) - .withLockContext(lockContext.data) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should bulk encrypt numbers with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - const intPayloads = [ - { id: 'user1', plaintext: 25 }, - { id: 'user2', plaintext: 30 }, - ] - - const encryptedData = await protectClient - .bulkEncrypt(intPayloads, { - column: users.age, - table: users, - }) - .withLockContext(lockContext.data) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(2) - expect(encryptedData.data[0]).toHaveProperty('id', 'user1') - expect(encryptedData.data[0]).toHaveProperty('data') - expect(encryptedData.data[0].data).toHaveProperty('c') - expect(encryptedData.data[1]).toHaveProperty('id', 'user2') - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[1].data).toHaveProperty('c') - - // Decrypt with lock context - const decryptedData = await protectClient - .bulkDecrypt(encryptedData.data) - .withLockContext(lockContext.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify decrypted data - expect(decryptedData.data).toHaveLength(2) - expect(decryptedData.data[0]).toHaveProperty('id', 'user1') - expect(decryptedData.data[0]).toHaveProperty('data', 25) - expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', 30) - }, 30000) -}) - -describe('Nested object encryption', () => { - it('should encrypt and decrypt nested number objects', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '1', - email: 'test@example.com', - metadata: { - count: 100, - level: 5, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.metadata?.count).toHaveProperty('c') - expect(encryptedModel.data.metadata?.level).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle null values in nested objects with number fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel: User = { - id: '2', - email: 'test2@example.com', - metadata: { - count: undefined, - level: undefined, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify null fields are preserved - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.metadata?.count).toBeUndefined() - expect(encryptedModel.data.metadata?.level).toBeUndefined() - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle undefined values in nested objects with number fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '3', - email: 'test3@example.com', - metadata: { - count: undefined, - level: undefined, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify undefined fields are preserved - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.metadata?.count).toBeUndefined() - expect(encryptedModel.data.metadata?.level).toBeUndefined() - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) -}) - -describe('encryptQuery for numbers', () => { - it('should create encrypted query for number fields', async () => { - const result = await protectClient.encryptQuery([ - { value: 25, column: users.age, table: users, queryType: 'equality' }, - { value: 100, column: users.score, table: users, queryType: 'equality' }, - ]) - - if (result.failure) { - throw new Error(`[protect]: ${result.failure.message}`) - } - - expect(result.data).toHaveLength(2) - expect(result.data[0]).toHaveProperty('v', 2) - expect(result.data[1]).toHaveProperty('v', 2) - }, 30000) -}) - -describe('Performance tests', () => { - it('should handle large numbers of numbers efficiently', async () => { - const largeNumArray = Array.from({ length: 100 }, (_, i) => ({ - id: i, - data: { - age: i + 18, // Ages 18-117 - score: (i % 100) + 1, // Scores 1-100 - }, - })) - - const numPayloads = largeNumArray.map((item, index) => ({ - id: `user${index}`, - plaintext: item.data.age, - })) - - const encryptedData = await protectClient.bulkEncrypt(numPayloads, { - column: users.age, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(100) - - // Decrypt the data - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify all data is preserved - expect(decryptedData.data).toHaveLength(100) - - for (let i = 0; i < 100; i++) { - expect(decryptedData.data[i].id).toBe(`user${i}`) - expect(decryptedData.data[i].data).toEqual(largeNumArray[i].data.age) - } - }, 60000) -}) - -describe('Advanced scenarios', () => { - it('should handle boundary values', async () => { - const boundaryValues = [ - Number.MIN_SAFE_INTEGER, - -2147483648, // Min 32-bit signed integer - -1, - 0, - 1, - 2147483647, // Max 32-bit signed integer - Number.MAX_SAFE_INTEGER, - ] - - for (const value of boundaryValues) { - const ciphertext = await protectClient.encrypt(value, { - column: users.age, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: value, - }) - } - }, 30000) -}) - -const invalidPlaintexts = [ - '400', - 'aaa', - '100a', - '73.51', - {}, - [], - [123], - { num: 123 }, -] - -describe('Invalid or uncoercable values', () => { - test.each(invalidPlaintexts)('should fail to encrypt', async (input) => { - const result = await protectClient.encrypt(input, { - column: users.age, - table: users, - }) - - expect(result.failure).toBeDefined() - expect(result.failure?.message).toContain('Cannot convert') - }, 30000) -}) diff --git a/packages/protect/__tests__/protect-ops.test.ts b/packages/protect/__tests__/protect-ops.test.ts deleted file mode 100644 index c7a2e2765..000000000 --- a/packages/protect/__tests__/protect-ops.test.ts +++ /dev/null @@ -1,843 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable } from '@cipherstash/schema' -import { beforeAll, describe, expect, it } from 'vitest' -import { LockContext, protect } from '../src' - -const users = csTable('users', { - email: csColumn('email').freeTextSearch().equality().orderAndRange(), - address: csColumn('address').freeTextSearch(), -}) - -type User = { - id: string - email?: string | null - createdAt?: Date - updatedAt?: Date - address?: string | null - number?: number -} - -let protectClient: Awaited> - -beforeAll(async () => { - protectClient = await protect({ - schemas: [users], - }) -}) - -describe('encryption and decryption edge cases', () => { - it('should return null if plaintext is null', async () => { - const ciphertext = await protectClient.encrypt(null, { - column: users.email, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify null is preserved - expect(ciphertext.data).toBeNull() - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: null, - }) - }, 30000) - - it('should encrypt and decrypt a model', async () => { - // Create a model with decrypted values - const decryptedModel = { - id: '1', - email: 'plaintext', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - address: '123 Main St', - number: 1, - } - - // Encrypt the model - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.address).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.number).toBe(1) - - // Decrypt the model - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual({ - id: '1', - email: 'plaintext', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - address: '123 Main St', - number: 1, - }) - }, 30000) - - it('should handle null values in a model', async () => { - // Create a model with null values - const decryptedModel = { - id: '1', - email: null, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - address: null, - } - - // Encrypt the model - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify null fields are preserved - expect(encryptedModel.data.email).toBeNull() - expect(encryptedModel.data.address).toBeNull() - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.number).toBe(1) - - // Decrypt the model - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual({ - id: '1', - email: null, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - address: null, - }) - }, 30000) - - it('should handle undefined values in a model', async () => { - // Create a model with undefined values - const decryptedModel = { - id: '1', - email: undefined, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - address: null, - } - - // Encrypt the model - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify undefined fields are preserved - expect(encryptedModel.data.email).toBeUndefined() - expect(encryptedModel.data.address).toBeNull() - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.number).toBe(1) - - // Decrypt the model - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual({ - id: '1', - email: undefined, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - address: null, - }) - }, 30000) -}) - -describe('bulk encryption', () => { - it('should bulk encrypt and decrypt models', async () => { - // Create models with decrypted values - const decryptedModels = [ - { - id: '1', - email: 'test', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - address: '123 Main St', - }, - { - id: '2', - email: 'test2', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 2, - address: null, - }, - ] - - // Encrypt the models - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields for each model - expect(encryptedModels.data[0].email).toHaveProperty('c') - expect(encryptedModels.data[0].address).toHaveProperty('c') - expect(encryptedModels.data[1].email).toHaveProperty('c') - expect(encryptedModels.data[1].address).toBeNull() - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[0].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].number).toBe(1) - expect(encryptedModels.data[1].id).toBe('2') - expect(encryptedModels.data[1].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].number).toBe(2) - - // Decrypt the models - const decryptedResult = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual([ - { - id: '1', - email: 'test', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - address: '123 Main St', - }, - { - id: '2', - email: 'test2', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 2, - address: null, - }, - ]) - }, 30000) - - it('should return empty array if models is empty', async () => { - // Encrypt empty array of models - const encryptedModels = await protectClient.bulkEncryptModels( - [], - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - expect(encryptedModels.data).toEqual([]) - }, 30000) - - it('should return empty array if decrypting empty array of models', async () => { - // Decrypt empty array of models - const decryptedResult = await protectClient.bulkDecryptModels([]) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual([]) - }, 30000) -}) - -describe('bulk encryption edge cases', () => { - it('should handle mixed null and non-null values in bulk operations', async () => { - const decryptedModels = [ - { - id: '1', - email: 'test1', - address: null, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - }, - { - id: '2', - email: null, - address: '123 Main St', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 2, - }, - { - id: '3', - email: 'test3', - address: '456 Oak St', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 3, - }, - ] - - // Encrypt the models - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields for each model - expect(encryptedModels.data[0].email).toHaveProperty('c') - expect(encryptedModels.data[0].address).toBeNull() - expect(encryptedModels.data[1].email).toBeNull() - expect(encryptedModels.data[1].address).toHaveProperty('c') - expect(encryptedModels.data[2].email).toHaveProperty('c') - expect(encryptedModels.data[2].address).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[0].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].number).toBe(1) - expect(encryptedModels.data[1].id).toBe('2') - expect(encryptedModels.data[1].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].number).toBe(2) - expect(encryptedModels.data[2].id).toBe('3') - expect(encryptedModels.data[2].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[2].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[2].number).toBe(3) - - // Decrypt the models - const decryptedResult = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModels) - }, 30000) - - it('should handle mixed undefined and non-undefined values in bulk operations', async () => { - const decryptedModels = [ - { - id: '1', - email: 'test1', - address: undefined, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - }, - { - id: '2', - email: null, - address: '123 Main St', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 2, - }, - { - id: '3', - email: 'test3', - address: '456 Oak St', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 3, - }, - ] - - // Encrypt the models - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields for each model - expect(encryptedModels.data[0].email).toHaveProperty('c') - expect(encryptedModels.data[0].address).toBeUndefined() - expect(encryptedModels.data[1].email).toBeNull() - expect(encryptedModels.data[1].address).toHaveProperty('c') - expect(encryptedModels.data[2].email).toHaveProperty('c') - expect(encryptedModels.data[2].address).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[0].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].number).toBe(1) - expect(encryptedModels.data[1].id).toBe('2') - expect(encryptedModels.data[1].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].number).toBe(2) - expect(encryptedModels.data[2].id).toBe('3') - expect(encryptedModels.data[2].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[2].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[2].number).toBe(3) - - // Decrypt the models - const decryptedResult = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModels) - }, 30000) - - it('should handle empty models in bulk operations', async () => { - const decryptedModels = [ - { - id: '1', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - }, // No encrypted fields - { - id: '2', - email: 'test2', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 2, - }, - { - id: '3', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 3, - }, // No encrypted fields - ] - - // Encrypt the models - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields for each model - expect(encryptedModels.data[0].email).toBeUndefined() - expect(encryptedModels.data[0].address).toBeUndefined() - expect(encryptedModels.data[1].email).toHaveProperty('c') - expect(encryptedModels.data[1].address).toBeUndefined() - expect(encryptedModels.data[2].email).toBeUndefined() - expect(encryptedModels.data[2].address).toBeUndefined() - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[0].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].number).toBe(1) - expect(encryptedModels.data[1].id).toBe('2') - expect(encryptedModels.data[1].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].number).toBe(2) - expect(encryptedModels.data[2].id).toBe('3') - expect(encryptedModels.data[2].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[2].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[2].number).toBe(3) - - // Decrypt the models - const decryptedResult = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModels) - }, 30000) -}) - -describe('error handling', () => { - it('should handle invalid encrypted payloads', async () => { - const validModel = { - id: '1', - email: 'test@example.com', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - address: '123 Main St', - number: 1, - } - - // First encrypt a valid model - const encryptedModel = await protectClient.encryptModel( - validModel, - users, - ) - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Create an invalid model by removing required fields - const invalidModel = { - id: '1', - // Missing required fields - } - - try { - await protectClient.decryptModel(invalidModel as User) - throw new Error('Expected decryption to fail') - } catch (error) { - expect(error).toBeDefined() - } - }, 30000) - - it('should handle missing required fields', async () => { - const model = { - id: '1', - email: null, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - address: null, - number: 1, - } - - try { - await protectClient.encryptModel(model, users) - throw new Error('Expected encryption to fail') - } catch (error) { - expect(error).toBeDefined() - } - }, 30000) -}) - -describe('type safety', () => { - it('should maintain type safety with complex nested objects', async () => { - const model = { - id: '1', - email: 'test@example.com', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - address: '123 Main St', - number: 1, - metadata: { - preferences: { - notifications: true, - theme: 'dark', - }, - }, - } - - // Encrypt the model - const encryptedModel = await protectClient.encryptModel(model, users) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Decrypt the model - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(model) - }, 30000) -}) - -describe('performance', () => { - it('should handle large numbers of models efficiently', async () => { - const largeModels = Array(10) - .fill(null) - .map((_, i) => ({ - id: i.toString(), - email: `test${i}@example.com`, - address: `Address ${i}`, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: i, - })) - - // Encrypt the models - const encryptedModels = await protectClient.bulkEncryptModels( - largeModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Decrypt the models - const decryptedResult = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(largeModels) - }, 60000) -}) - -describe('encryption and decryption with lock context', () => { - it('should encrypt and decrypt a payload with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - const email = 'hello@example.com' - - const ciphertext = await protectClient - .encrypt(email, { - column: users.email, - table: users, - }) - .withLockContext(lockContext.data) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - const plaintext = await protectClient - .decrypt(ciphertext.data) - .withLockContext(lockContext.data) - - if (plaintext.failure) { - throw new Error(`[protect]: ${plaintext.failure.message}`) - } - - expect(plaintext.data).toEqual(email) - }, 30000) - - it('should encrypt and decrypt a model with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - // Create a model with decrypted values - const decryptedModel = { - id: '1', - email: 'plaintext', - } - - // Encrypt the model with lock context - const encryptedModel = await protectClient - .encryptModel(decryptedModel, users) - .withLockContext(lockContext.data) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Decrypt the model with lock context - const decryptedResult = await protectClient - .decryptModel(encryptedModel.data) - .withLockContext(lockContext.data) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual({ - id: '1', - email: 'plaintext', - }) - }, 30000) - - it('should encrypt with context and be unable to decrypt without context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - // Create a model with decrypted values - const decryptedModel = { - id: '1', - email: 'plaintext', - } - - // Encrypt the model with lock context - const encryptedModel = await protectClient - .encryptModel(decryptedModel, users) - .withLockContext(lockContext.data) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - try { - await protectClient.decryptModel(encryptedModel.data) - } catch (error) { - const e = error as Error - expect(e.message.startsWith('Failed to retrieve key')).toEqual(true) - } - }, 30000) - - it('should bulk encrypt and decrypt models with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - // Create models with decrypted values - const decryptedModels = [ - { - id: '1', - email: 'test', - }, - { - id: '2', - email: 'test2', - }, - ] - - // Encrypt the models with lock context - const encryptedModels = await protectClient - .bulkEncryptModels(decryptedModels, users) - .withLockContext(lockContext.data) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Decrypt the models with lock context - const decryptedResult = await protectClient - .bulkDecryptModels(encryptedModels.data) - .withLockContext(lockContext.data) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual([ - { - id: '1', - email: 'test', - }, - { - id: '2', - email: 'test2', - }, - ]) - }, 30000) -}) - -describe('special characters', () => { - it('should encrypt and decrypt multiple special characters together', async () => { - const plaintext = - 'complex@string-with/slashes\\backslashes.and#symbols$%&+!@#$%^&*()_+-=[]{}|;:,.<>?/~`' - - const ciphertext = await protectClient.encrypt(plaintext, { - column: users.email, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - const decrypted = await protectClient.decrypt(ciphertext.data) - - expect(decrypted).toEqual({ - data: plaintext, - }) - }, 30000) -}) diff --git a/packages/protect/__tests__/searchable-json-pg.test.ts b/packages/protect/__tests__/searchable-json-pg.test.ts deleted file mode 100644 index 49b13af02..000000000 --- a/packages/protect/__tests__/searchable-json-pg.test.ts +++ /dev/null @@ -1,2390 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable } from '@cipherstash/schema' -import postgres from 'postgres' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { LockContext, protect } from '../src' - -if (!process.env.DATABASE_URL) { - throw new Error('Missing env.DATABASE_URL') -} - -// Disable prepared statements — required for pooled connections (PgBouncer in transaction mode) -const sql = postgres(process.env.DATABASE_URL, { prepare: false }) - -const table = csTable('protect-ci-jsonb', { - metadata: csColumn('metadata').searchableJson(), -}) - -const TEST_RUN_ID = `test-run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` -const userJwt = process.env.USER_JWT - -type ProtectClient = Awaited> -let protectClient: ProtectClient - -// ─── Helpers ───────────────────────────────────────────────────────── - -async function insertRow(plaintext: any) { - const encrypted = await protectClient.encryptModel( - { metadata: plaintext }, - table, - ) - if (encrypted.failure) throw new Error(encrypted.failure.message) - - const [inserted] = await sql` - INSERT INTO "protect-ci-jsonb" (metadata, test_run_id) - VALUES (${sql.json(encrypted.data.metadata)}::eql_v2_encrypted, ${TEST_RUN_ID}) - RETURNING id - ` - return { id: inserted.id, encrypted } -} - -async function verifyRow(row: any, expected: any) { - expect(row).toBeDefined() - const decrypted = await protectClient.decryptModel({ metadata: row.metadata }) - if (decrypted.failure) throw new Error(decrypted.failure.message) - expect(decrypted.data.metadata).toEqual(expected) -} - -async function encryptQueryTerm( - value: any, - queryType: 'steVecSelector' | 'steVecTerm' | 'searchableJson', - returnType: - | 'composite-literal' - | 'escaped-composite-literal' = 'composite-literal', -) { - const result = await protectClient.encryptQuery(value, { - column: table.metadata, - table: table, - queryType, - returnType, - }) - if (result.failure) throw new Error(result.failure.message) - return result.data -} - -beforeAll(async () => { - protectClient = await protect({ schemas: [table] }) - - await sql` - CREATE TABLE IF NOT EXISTS "protect-ci-jsonb" ( - id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - metadata eql_v2_encrypted, - test_run_id TEXT - ) - ` -}, 30000) - -afterAll(async () => { - await sql`DELETE FROM "protect-ci-jsonb" WHERE test_run_id = ${TEST_RUN_ID}` - await sql.end() -}, 30000) - -describe('searchableJson postgres integration', () => { - // ─── Storage: encrypt → insert → select → decrypt ────────────────── - - describe('storage: encrypt → insert → select → decrypt', () => { - it('round-trips a flat JSON object', async () => { - const plaintext = { user: { email: 'flat-rt@test.com' }, role: 'admin' } - const { id } = await insertRow(plaintext) - - const rows = await sql` - SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" - WHERE id = ${id} - ` - - expect(rows).toHaveLength(1) - await verifyRow(rows[0], plaintext) - }, 30000) - - it('round-trips nested JSON with arrays', async () => { - const plaintext = { - user: { - profile: { role: 'admin', permissions: ['read', 'write'] }, - tags: [{ name: 'vip' }, { name: 'beta' }], - }, - items: [{ id: 1, name: 'widget' }], - } - const { id } = await insertRow(plaintext) - - const rows = await sql` - SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" - WHERE id = ${id} - ` - - expect(rows).toHaveLength(1) - await verifyRow(rows[0], plaintext) - }, 30000) - - it('round-trips null values', async () => { - const encrypted = await protectClient.encrypt(null, { - column: table.metadata, - table: table, - }) - - if (encrypted.failure) throw new Error(encrypted.failure.message) - expect(encrypted.data).toBeNull() - - const [inserted] = await sql` - INSERT INTO "protect-ci-jsonb" (metadata, test_run_id) - VALUES (NULL, ${TEST_RUN_ID}) - RETURNING id - ` - - const rows = await sql` - SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" - WHERE id = ${inserted.id} - ` - - expect(rows).toHaveLength(1) - expect(rows[0].metadata).toBeNull() - }, 30000) - }) - - // ─── jsonb_path_query: path-based selector queries ───────────────── - - describe('jsonb_path_query: path-based selector queries', () => { - it('finds row by simple top-level path ($.role)', async () => { - const plaintext = { role: 'path-toplevel-test', extra: 'data' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('finds row by nested path ($.user.email)', async () => { - const plaintext = { - user: { email: 'nested-path@test.com' }, - type: 'nested-path', - } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.user.email', - 'steVecSelector', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('finds row by deeply nested path ($.a.b.c)', async () => { - const plaintext = { a: { b: { c: 'deep-value' } }, marker: 'deep-path' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.a.b.c', 'steVecSelector') - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('non-matching path returns zero rows', async () => { - // Insert a doc that does NOT have $.nonexistent.path - const plaintext = { exists: true, marker: 'no-match-test' } - await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.nonexistent.path', - 'steVecSelector', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - // No row should have this path - expect(rows.length).toBe(0) - }, 30000) - - it('multiple docs — only matching doc returned', async () => { - // Insert two docs: one with $.target.value, one without - const plaintextWithPath = { - target: { value: 'found-it' }, - marker: 'has-target', - } - const plaintextWithoutPath = { - other: { key: 'nope' }, - marker: 'no-target', - } - - const { id: idWith } = await insertRow(plaintextWithPath) - const { id: idWithout } = await insertRow(plaintextWithoutPath) - - const selectorTerm = await encryptQueryTerm( - '$.target.value', - 'steVecSelector', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - // The doc with $.target.value should be found - const matchingRow = rows.find((r) => r.id === idWith) - expect(matchingRow).toBeDefined() - - // The doc without $.target.value should NOT be found - const nonMatchingRow = rows.find((r) => r.id === idWithout) - expect(nonMatchingRow).toBeUndefined() - - // Decrypt and verify the matching row - await verifyRow(matchingRow!, plaintextWithPath) - }, 30000) - - it('finds row by simple top-level path (Simple)', async () => { - const plaintext = { role: 'path-tl-simple', extra: 'data' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, '${selectorTerm}'::eql_v2_encrypted) as result - WHERE t.test_run_id = '${TEST_RUN_ID}'`, - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('finds row by nested path (Simple)', async () => { - const plaintext = { - user: { email: 'nested-simple@test.com' }, - type: 'nested-path-simple', - } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.user.email', - 'steVecSelector', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, '${selectorTerm}'::eql_v2_encrypted) as result - WHERE t.test_run_id = '${TEST_RUN_ID}'`, - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('finds with deep nested path (Simple)', async () => { - const plaintext = { - target: { nested: { value: 'deep-simple' } }, - marker: 'jpq-deep-simple', - } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.target.nested.value', - 'steVecSelector', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, '${selectorTerm}'::eql_v2_encrypted) as result - WHERE t.test_run_id = '${TEST_RUN_ID}'`, - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('non-matching path returns zero rows (Simple)', async () => { - const plaintext = { data: true, marker: 'jpq-nomatch-simple' } - await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.missing.path', - 'steVecSelector', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, '${selectorTerm}'::eql_v2_encrypted) as result - WHERE t.test_run_id = '${TEST_RUN_ID}'`, - ) - - expect(rows.length).toBe(0) - }, 30000) - }) - - // ─── Containment: @> term queries ────────────────────────────────── - - describe('containment: @> term queries', () => { - it('matches by key/value pair', async () => { - const plaintext = { role: 'admin-containment', department: 'engineering' } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { role: 'admin-containment' }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.metadata @> ${containmentTerm}::eql_v2_encrypted - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('matches by nested object structure', async () => { - const plaintext = { - user: { profile: { role: 'superadmin' } }, - active: true, - } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { user: { profile: { role: 'superadmin' } } }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.metadata @> ${containmentTerm}::eql_v2_encrypted - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('non-matching term returns zero rows', async () => { - const plaintext = { status: 'active', tier: 'free' } - await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { status: 'nonexistent-value-xyz' }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.metadata @> ${containmentTerm}::eql_v2_encrypted - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBe(0) - }, 30000) - }) - - // ─── Mixed and batch operations ──────────────────────────────────── - - describe('mixed and batch operations', () => { - it('batch encrypts selector + containment terms together', async () => { - const plaintext = { - user: { email: 'batch@test.com' }, - role: 'editor', - kind: 'batch-mixed', - } - const { id } = await insertRow(plaintext) - - const queryResult = await protectClient.encryptQuery([ - { - value: '$.user.email', - column: table.metadata, - table: table, - queryType: 'steVecSelector', - returnType: 'composite-literal', - }, - { - value: { role: 'editor' }, - column: table.metadata, - table: table, - queryType: 'steVecTerm', - returnType: 'composite-literal', - }, - ]) - - if (queryResult.failure) throw new Error(queryResult.failure.message) - const [selectorTerm, containmentTerm] = queryResult.data - - // Selector query: jsonb_path_query - const selectorRows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - expect(selectorRows.length).toBeGreaterThanOrEqual(1) - const selectorMatch = selectorRows.find((r) => r.id === id) - expect(selectorMatch).toBeDefined() - await verifyRow(selectorMatch!, plaintext) - - // Containment query: @> - const containmentRows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" - WHERE metadata @> ${containmentTerm}::eql_v2_encrypted - AND test_run_id = ${TEST_RUN_ID} - ` - - expect(containmentRows.length).toBeGreaterThanOrEqual(1) - const containmentMatch = containmentRows.find((r) => r.id === id) - expect(containmentMatch).toBeDefined() - await verifyRow(containmentMatch!, plaintext) - }, 30000) - - it('inferred vs explicit queryType produce same results', async () => { - const plaintext = { category: 'equivalence-test', priority: 'high' } - const { id } = await insertRow(plaintext) - - // Selector: inferred (searchableJson) vs explicit (steVecSelector) - const inferredSelectorTerm = await encryptQueryTerm( - '$.category', - 'searchableJson', - ) - const explicitSelectorTerm = await encryptQueryTerm( - '$.category', - 'steVecSelector', - ) - - const inferredRows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${inferredSelectorTerm}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - const explicitRows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${explicitSelectorTerm}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - expect(inferredRows.length).toBe(explicitRows.length) - expect(inferredRows.length).toBeGreaterThanOrEqual(1) - - // Both should find our inserted row - const inferredMatch = inferredRows.find((r) => r.id === id) - const explicitMatch = explicitRows.find((r) => r.id === id) - expect(inferredMatch).toBeDefined() - expect(explicitMatch).toBeDefined() - - // Decrypt and compare — both should yield identical plaintext - const inferredDecrypted = await protectClient.decryptModel({ - metadata: inferredMatch!.metadata, - }) - const explicitDecrypted = await protectClient.decryptModel({ - metadata: explicitMatch!.metadata, - }) - if (inferredDecrypted.failure) - throw new Error(inferredDecrypted.failure.message) - if (explicitDecrypted.failure) - throw new Error(explicitDecrypted.failure.message) - - expect(inferredDecrypted.data.metadata).toEqual( - explicitDecrypted.data.metadata, - ) - expect(inferredDecrypted.data.metadata).toEqual(plaintext) - - // Containment: inferred (searchableJson) vs explicit (steVecTerm) - const inferredContainmentTerm = await encryptQueryTerm( - { category: 'equivalence-test' }, - 'searchableJson', - ) - const explicitContainmentTerm = await encryptQueryTerm( - { category: 'equivalence-test' }, - 'steVecTerm', - ) - - const inferredTermRows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" - WHERE metadata @> ${inferredContainmentTerm}::eql_v2_encrypted - AND test_run_id = ${TEST_RUN_ID} - ` - - const explicitTermRows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" - WHERE metadata @> ${explicitContainmentTerm}::eql_v2_encrypted - AND test_run_id = ${TEST_RUN_ID} - ` - - expect(inferredTermRows.length).toBe(explicitTermRows.length) - expect(inferredTermRows.length).toBeGreaterThanOrEqual(1) - - const inferredTermMatch = inferredTermRows.find((r) => r.id === id) - const explicitTermMatch = explicitTermRows.find((r) => r.id === id) - expect(inferredTermMatch).toBeDefined() - expect(explicitTermMatch).toBeDefined() - - const inferredTermDecrypted = await protectClient.decryptModel({ - metadata: inferredTermMatch!.metadata, - }) - const explicitTermDecrypted = await protectClient.decryptModel({ - metadata: explicitTermMatch!.metadata, - }) - if (inferredTermDecrypted.failure) - throw new Error(inferredTermDecrypted.failure.message) - if (explicitTermDecrypted.failure) - throw new Error(explicitTermDecrypted.failure.message) - - expect(inferredTermDecrypted.data.metadata).toEqual( - explicitTermDecrypted.data.metadata, - ) - expect(inferredTermDecrypted.data.metadata).toEqual(plaintext) - }, 30000) - }) - - // ─── Escaped-composite-literal format ───────────────────────────── - - describe('escaped-composite-literal format', () => { - it('escaped selector → unwrap → query PG', async () => { - const plaintext = { - user: { email: 'escaped-sel@test.com' }, - marker: 'escaped-selector', - } - const { id } = await insertRow(plaintext) - - // Encrypt with both formats - const compositeData = await encryptQueryTerm( - '$.user.email', - 'steVecSelector', - 'composite-literal', - ) - const escapedData = (await encryptQueryTerm( - '$.user.email', - 'steVecSelector', - 'escaped-composite-literal', - )) as string - - // Verify escaped format and unwrap - expect(typeof escapedData).toBe('string') - expect(escapedData).toMatch(/^"\(.*\)"$/) - const unwrapped = JSON.parse(escapedData) - - expect(unwrapped).toBe(compositeData) - - // Use composite-literal form to query PG - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${compositeData}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('escaped containment → unwrap → query PG', async () => { - const plaintext = { - role: 'escaped-containment-test', - department: 'security', - } - const { id } = await insertRow(plaintext) - - const escapedData = (await encryptQueryTerm( - { role: 'escaped-containment-test' }, - 'steVecTerm', - 'escaped-composite-literal', - )) as string - - // Verify escaped format and unwrap - expect(typeof escapedData).toBe('string') - expect(escapedData).toMatch(/^"\(.*\)"$/) - const unwrapped = JSON.parse(escapedData) - - // Unwrapped escaped format should be a valid composite-literal - expect(typeof unwrapped).toBe('string') - expect(unwrapped).toMatch(/^\(.*\)$/) - - // Use unwrapped composite-literal form to query PG - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" - WHERE metadata @> ${unwrapped}::eql_v2_encrypted - AND test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('batch escaped format', async () => { - const plaintext = { - user: { email: 'batch-escaped@test.com' }, - role: 'batch-escaped-role', - marker: 'batch-escaped', - } - const { id } = await insertRow(plaintext) - - const queryResult = await protectClient.encryptQuery([ - { - value: '$.user.email', - column: table.metadata, - table: table, - queryType: 'steVecSelector', - returnType: 'escaped-composite-literal', - }, - { - value: { role: 'batch-escaped-role' }, - column: table.metadata, - table: table, - queryType: 'steVecTerm', - returnType: 'escaped-composite-literal', - }, - ]) - if (queryResult.failure) throw new Error(queryResult.failure.message) - - expect(queryResult.data).toHaveLength(2) - for (const item of queryResult.data) { - expect(typeof item).toBe('string') - expect(item).toMatch(/^"\(.*\)"$/) - } - - // Unwrap escaped format - const selectorUnwrapped = JSON.parse(queryResult.data[0] as string) - const containmentUnwrapped = JSON.parse(queryResult.data[1] as string) - - // Selector query - const selectorRows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${selectorUnwrapped}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - expect(selectorRows.length).toBeGreaterThanOrEqual(1) - const selectorMatch = selectorRows.find((r) => r.id === id) - expect(selectorMatch).toBeDefined() - await verifyRow(selectorMatch!, plaintext) - - // Containment query - const containmentRows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" - WHERE metadata @> ${containmentUnwrapped}::eql_v2_encrypted - AND test_run_id = ${TEST_RUN_ID} - ` - - expect(containmentRows.length).toBeGreaterThanOrEqual(1) - const containmentMatch = containmentRows.find((r) => r.id === id) - expect(containmentMatch).toBeDefined() - await verifyRow(containmentMatch!, plaintext) - }, 30000) - }) - - // ─── LockContext integration ────────────────────────────────────── - - describe.skipIf(!userJwt)('LockContext integration', () => { - it('selector with LockContext', async () => { - const lc = new LockContext() - const lockContext = await lc.identify(userJwt!) - if (lockContext.failure) throw new Error(lockContext.failure.message) - - const plaintext = { - user: { email: 'lc-selector@test.com' }, - marker: 'lock-context-selector', - } - - const encrypted = await protectClient - .encryptModel({ metadata: plaintext }, table) - .withLockContext(lockContext.data) - if (encrypted.failure) throw new Error(encrypted.failure.message) - - const [inserted] = await sql` - INSERT INTO "protect-ci-jsonb" (metadata, test_run_id) - VALUES (${sql.json(encrypted.data.metadata)}::eql_v2_encrypted, ${TEST_RUN_ID}) - RETURNING id - ` - - const selectorResult = await protectClient - .encryptQuery('$.user.email', { - column: table.metadata, - table: table, - queryType: 'steVecSelector', - returnType: 'composite-literal', - }) - .withLockContext(lockContext.data) - .execute() - if (selectorResult.failure) - throw new Error(selectorResult.failure.message) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${selectorResult.data}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === inserted.id) - expect(matchingRow).toBeDefined() - - const decrypted = await protectClient - .decryptModel({ metadata: matchingRow!.metadata }) - .withLockContext(lockContext.data) - if (decrypted.failure) throw new Error(decrypted.failure.message) - expect(decrypted.data.metadata).toEqual(plaintext) - }, 60000) - - it('containment with LockContext', async () => { - const lc = new LockContext() - const lockContext = await lc.identify(userJwt!) - if (lockContext.failure) throw new Error(lockContext.failure.message) - - const plaintext = { role: 'lc-containment-test', department: 'auth' } - - const encrypted = await protectClient - .encryptModel({ metadata: plaintext }, table) - .withLockContext(lockContext.data) - if (encrypted.failure) throw new Error(encrypted.failure.message) - - const [inserted] = await sql` - INSERT INTO "protect-ci-jsonb" (metadata, test_run_id) - VALUES (${sql.json(encrypted.data.metadata)}::eql_v2_encrypted, ${TEST_RUN_ID}) - RETURNING id - ` - - const containmentResult = await protectClient - .encryptQuery( - { role: 'lc-containment-test' }, - { - column: table.metadata, - table: table, - queryType: 'steVecTerm', - returnType: 'composite-literal', - }, - ) - .withLockContext(lockContext.data) - .execute() - if (containmentResult.failure) - throw new Error(containmentResult.failure.message) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" - WHERE metadata @> ${containmentResult.data}::eql_v2_encrypted - AND test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === inserted.id) - expect(matchingRow).toBeDefined() - - const decrypted = await protectClient - .decryptModel({ metadata: matchingRow!.metadata }) - .withLockContext(lockContext.data) - if (decrypted.failure) throw new Error(decrypted.failure.message) - expect(decrypted.data.metadata).toEqual(plaintext) - }, 60000) - - it('batch with LockContext', async () => { - const lc = new LockContext() - const lockContext = await lc.identify(userJwt!) - if (lockContext.failure) throw new Error(lockContext.failure.message) - - const plaintext = { - user: { email: 'lc-batch@test.com' }, - role: 'lc-batch-role', - kind: 'lock-context-batch', - } - - const encrypted = await protectClient - .encryptModel({ metadata: plaintext }, table) - .withLockContext(lockContext.data) - if (encrypted.failure) throw new Error(encrypted.failure.message) - - const [inserted] = await sql` - INSERT INTO "protect-ci-jsonb" (metadata, test_run_id) - VALUES (${sql.json(encrypted.data.metadata)}::eql_v2_encrypted, ${TEST_RUN_ID}) - RETURNING id - ` - - const batchResult = await protectClient - .encryptQuery([ - { - value: '$.user.email', - column: table.metadata, - table: table, - queryType: 'steVecSelector', - returnType: 'composite-literal', - }, - { - value: { role: 'lc-batch-role' }, - column: table.metadata, - table: table, - queryType: 'steVecTerm', - returnType: 'composite-literal', - }, - ]) - .withLockContext(lockContext.data) - .execute() - if (batchResult.failure) throw new Error(batchResult.failure.message) - - const [selectorTerm, containmentTerm] = batchResult.data - - // Selector query - const selectorRows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - expect(selectorRows.length).toBeGreaterThanOrEqual(1) - const selectorMatch = selectorRows.find((r) => r.id === inserted.id) - expect(selectorMatch).toBeDefined() - - const selectorDecrypted = await protectClient - .decryptModel({ metadata: selectorMatch!.metadata }) - .withLockContext(lockContext.data) - if (selectorDecrypted.failure) - throw new Error(selectorDecrypted.failure.message) - expect(selectorDecrypted.data.metadata).toEqual(plaintext) - - // Containment query - const containmentRows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" - WHERE metadata @> ${containmentTerm}::eql_v2_encrypted - AND test_run_id = ${TEST_RUN_ID} - ` - - expect(containmentRows.length).toBeGreaterThanOrEqual(1) - const containmentMatch = containmentRows.find((r) => r.id === inserted.id) - expect(containmentMatch).toBeDefined() - - const containmentDecrypted = await protectClient - .decryptModel({ metadata: containmentMatch!.metadata }) - .withLockContext(lockContext.data) - if (containmentDecrypted.failure) - throw new Error(containmentDecrypted.failure.message) - expect(containmentDecrypted.data.metadata).toEqual(plaintext) - }, 60000) - }) - - // ─── Concurrent query operations ───────────────────────────────── - - describe('concurrent query operations', () => { - it('parallel selector queries', async () => { - // Insert 3 docs with distinct structures - const docs = [ - { alpha: { key: 'concurrent-sel-1' }, marker: 'concurrent-1' }, - { beta: { key: 'concurrent-sel-2' }, marker: 'concurrent-2' }, - { gamma: { key: 'concurrent-sel-3' }, marker: 'concurrent-3' }, - ] - - const insertedIds: number[] = [] - for (const plaintext of docs) { - const { id } = await insertRow(plaintext) - insertedIds.push(id) - } - - // Parallel encrypt 3 selector queries - const [q1, q2, q3] = await Promise.all([ - protectClient.encryptQuery('$.alpha.key', { - column: table.metadata, - table: table, - queryType: 'steVecSelector', - returnType: 'composite-literal', - }), - protectClient.encryptQuery('$.beta.key', { - column: table.metadata, - table: table, - queryType: 'steVecSelector', - returnType: 'composite-literal', - }), - protectClient.encryptQuery('$.gamma.key', { - column: table.metadata, - table: table, - queryType: 'steVecSelector', - returnType: 'composite-literal', - }), - ]) - - if (q1.failure) throw new Error(q1.failure.message) - if (q2.failure) throw new Error(q2.failure.message) - if (q3.failure) throw new Error(q3.failure.message) - - // Execute each against PG - const [rows1, rows2, rows3] = await Promise.all([ - sql` - SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${q1.data}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - `, - sql` - SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${q2.data}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - `, - sql` - SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${q3.data}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - `, - ]) - - // Each query should find its respective doc and not others - expect(rows1.find((r) => r.id === insertedIds[0])).toBeDefined() - expect(rows1.find((r) => r.id === insertedIds[1])).toBeUndefined() - expect(rows1.find((r) => r.id === insertedIds[2])).toBeUndefined() - expect(rows2.find((r) => r.id === insertedIds[1])).toBeDefined() - expect(rows2.find((r) => r.id === insertedIds[0])).toBeUndefined() - expect(rows2.find((r) => r.id === insertedIds[2])).toBeUndefined() - expect(rows3.find((r) => r.id === insertedIds[2])).toBeDefined() - expect(rows3.find((r) => r.id === insertedIds[0])).toBeUndefined() - expect(rows3.find((r) => r.id === insertedIds[1])).toBeUndefined() - - // Decrypt and validate each matched row - const match1 = rows1.find((r) => r.id === insertedIds[0])! - const decrypted1 = await protectClient.decryptModel({ - metadata: match1.metadata, - }) - if (decrypted1.failure) throw new Error(decrypted1.failure.message) - expect(decrypted1.data.metadata).toEqual(docs[0]) - - const match2 = rows2.find((r) => r.id === insertedIds[1])! - const decrypted2 = await protectClient.decryptModel({ - metadata: match2.metadata, - }) - if (decrypted2.failure) throw new Error(decrypted2.failure.message) - expect(decrypted2.data.metadata).toEqual(docs[1]) - - const match3 = rows3.find((r) => r.id === insertedIds[2])! - const decrypted3 = await protectClient.decryptModel({ - metadata: match3.metadata, - }) - if (decrypted3.failure) throw new Error(decrypted3.failure.message) - expect(decrypted3.data.metadata).toEqual(docs[2]) - }, 60000) - - it('parallel containment queries', async () => { - const docs = [ - { role: 'concurrent-contain-1', tier: 'gold' }, - { role: 'concurrent-contain-2', tier: 'silver' }, - ] - - const insertedIds: number[] = [] - for (const plaintext of docs) { - const { id } = await insertRow(plaintext) - insertedIds.push(id) - } - - // Parallel encrypt 2 containment queries - const [c1, c2] = await Promise.all([ - protectClient.encryptQuery( - { role: 'concurrent-contain-1' }, - { - column: table.metadata, - table: table, - queryType: 'steVecTerm', - returnType: 'composite-literal', - }, - ), - protectClient.encryptQuery( - { role: 'concurrent-contain-2' }, - { - column: table.metadata, - table: table, - queryType: 'steVecTerm', - returnType: 'composite-literal', - }, - ), - ]) - - if (c1.failure) throw new Error(c1.failure.message) - if (c2.failure) throw new Error(c2.failure.message) - - const [rows1, rows2] = await Promise.all([ - sql` - SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" - WHERE metadata @> ${c1.data}::eql_v2_encrypted - AND test_run_id = ${TEST_RUN_ID} - `, - sql` - SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" - WHERE metadata @> ${c2.data}::eql_v2_encrypted - AND test_run_id = ${TEST_RUN_ID} - `, - ]) - - // Each finds only its target doc - expect(rows1.find((r) => r.id === insertedIds[0])).toBeDefined() - expect(rows1.find((r) => r.id === insertedIds[1])).toBeUndefined() - expect(rows2.find((r) => r.id === insertedIds[1])).toBeDefined() - expect(rows2.find((r) => r.id === insertedIds[0])).toBeUndefined() - - // Decrypt and validate each matched row - const match1 = rows1.find((r) => r.id === insertedIds[0])! - const decrypted1 = await protectClient.decryptModel({ - metadata: match1.metadata, - }) - if (decrypted1.failure) throw new Error(decrypted1.failure.message) - expect(decrypted1.data.metadata).toEqual(docs[0]) - - const match2 = rows2.find((r) => r.id === insertedIds[1])! - const decrypted2 = await protectClient.decryptModel({ - metadata: match2.metadata, - }) - if (decrypted2.failure) throw new Error(decrypted2.failure.message) - expect(decrypted2.data.metadata).toEqual(docs[1]) - }, 60000) - - it('parallel mixed encrypt+query', async () => { - const plaintext = { - user: { email: 'concurrent-mixed@test.com' }, - role: 'concurrent-mixed-role', - kind: 'mixed-concurrent', - } - - // Parallel: encryptModel + selector encryptQuery + containment encryptQuery - const [encryptedModel, selectorResult, containmentResult] = - await Promise.all([ - protectClient.encryptModel({ metadata: plaintext }, table), - protectClient.encryptQuery('$.user.email', { - column: table.metadata, - table: table, - queryType: 'steVecSelector', - returnType: 'composite-literal', - }), - protectClient.encryptQuery( - { role: 'concurrent-mixed-role' }, - { - column: table.metadata, - table: table, - queryType: 'steVecTerm', - returnType: 'composite-literal', - }, - ), - ]) - - if (encryptedModel.failure) - throw new Error(encryptedModel.failure.message) - if (selectorResult.failure) - throw new Error(selectorResult.failure.message) - if (containmentResult.failure) - throw new Error(containmentResult.failure.message) - - // Insert the encrypted doc - const [inserted] = await sql` - INSERT INTO "protect-ci-jsonb" (metadata, test_run_id) - VALUES (${sql.json(encryptedModel.data.metadata)}::eql_v2_encrypted, ${TEST_RUN_ID}) - RETURNING id - ` - - // Query with both terms - const [selectorRows, containmentRows] = await Promise.all([ - sql` - SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${selectorResult.data}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - `, - sql` - SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" - WHERE metadata @> ${containmentResult.data}::eql_v2_encrypted - AND test_run_id = ${TEST_RUN_ID} - `, - ]) - - // Both should find the inserted row - expect(selectorRows.find((r) => r.id === inserted.id)).toBeDefined() - expect(containmentRows.find((r) => r.id === inserted.id)).toBeDefined() - // Verify result sets are bounded (not returning all rows) - expect(selectorRows.length).toBeGreaterThanOrEqual(1) - expect(containmentRows.length).toBeGreaterThanOrEqual(1) - - // Decrypt and validate both matched rows - const selectorMatch = selectorRows.find((r) => r.id === inserted.id)! - const selectorDecrypted = await protectClient.decryptModel({ - metadata: selectorMatch.metadata, - }) - if (selectorDecrypted.failure) - throw new Error(selectorDecrypted.failure.message) - expect(selectorDecrypted.data.metadata).toEqual(plaintext) - - const containmentMatch = containmentRows.find( - (r) => r.id === inserted.id, - )! - const containmentDecrypted = await protectClient.decryptModel({ - metadata: containmentMatch.metadata, - }) - if (containmentDecrypted.failure) - throw new Error(containmentDecrypted.failure.message) - expect(containmentDecrypted.data.metadata).toEqual(plaintext) - }, 60000) - }) - - // ─── Contained-by: <@ term queries ──────────────────────────────── - - describe('contained-by: <@ term queries', () => { - it('matches by key/value pair (Extended)', async () => { - const plaintext = { role: 'contained-by-kv', department: 'eng' } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { role: 'contained-by-kv' }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE ${containmentTerm}::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('matches by nested object (Extended)', async () => { - const plaintext = { - user: { profile: { role: 'contained-by-nested' } }, - active: true, - } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { user: { profile: { role: 'contained-by-nested' } } }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE ${containmentTerm}::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('non-matching value returns zero rows (Extended)', async () => { - const plaintext = { status: 'active-cb', tier: 'free' } - await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { status: 'nonexistent-cb-xyz' }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE ${containmentTerm}::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBe(0) - }, 30000) - - it('matches by key/value pair (Simple)', async () => { - const plaintext = { role: 'contained-by-kv-simple', department: 'ops' } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { role: 'contained-by-kv-simple' }, - 'steVecTerm', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE $1::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = $2`, - [containmentTerm, TEST_RUN_ID], - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('matches by nested object (Simple)', async () => { - const plaintext = { - user: { profile: { role: 'contained-by-nested-simple' } }, - active: true, - } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { user: { profile: { role: 'contained-by-nested-simple' } } }, - 'steVecTerm', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE $1::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = $2`, - [containmentTerm, TEST_RUN_ID], - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('non-matching value returns zero rows (Simple)', async () => { - const plaintext = { status: 'active-cb-simple', tier: 'premium' } - await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { status: 'nonexistent-cb-simple-xyz' }, - 'steVecTerm', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE $1::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = $2`, - [containmentTerm, TEST_RUN_ID], - ) - - expect(rows.length).toBe(0) - }, 30000) - }) - - // ─── jsonb_path_query_first: scalar path queries ────────────────── - - describe('jsonb_path_query_first: scalar path queries', () => { - it('finds row by string field (Extended)', async () => { - const plaintext = { role: 'qf-string', extra: 'data' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE eql_v2.jsonb_path_query_first(t.metadata, ${selectorTerm}::eql_v2_encrypted) IS NOT NULL - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('finds row by nested path (Extended)', async () => { - const plaintext = { - user: { email: 'qf-nested@test.com' }, - type: 'qf-nested', - } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.user.email', - 'steVecSelector', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE eql_v2.jsonb_path_query_first(t.metadata, ${selectorTerm}::eql_v2_encrypted) IS NOT NULL - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('returns no rows for unknown path (Extended)', async () => { - const plaintext = { exists: true, marker: 'qf-nomatch' } - await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.nonexistent.path', - 'steVecSelector', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE eql_v2.jsonb_path_query_first(t.metadata, ${selectorTerm}::eql_v2_encrypted) IS NOT NULL - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBe(0) - }, 30000) - - it('finds row by string field (Simple)', async () => { - const plaintext = { role: 'qf-string-simple', extra: 'data' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE eql_v2.jsonb_path_query_first(t.metadata, '${selectorTerm}'::eql_v2_encrypted) IS NOT NULL - AND t.test_run_id = '${TEST_RUN_ID}'`, - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('finds row by nested path (Simple)', async () => { - const plaintext = { - user: { email: 'qf-nested-simple@test.com' }, - type: 'qf-nested-simple', - } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.user.email', - 'steVecSelector', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE eql_v2.jsonb_path_query_first(t.metadata, '${selectorTerm}'::eql_v2_encrypted) IS NOT NULL - AND t.test_run_id = '${TEST_RUN_ID}'`, - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('returns no rows for unknown path (Simple)', async () => { - const plaintext = { exists: true, marker: 'qf-nomatch-simple' } - await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.nonexistent.path', - 'steVecSelector', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE eql_v2.jsonb_path_query_first(t.metadata, '${selectorTerm}'::eql_v2_encrypted) IS NOT NULL - AND t.test_run_id = '${TEST_RUN_ID}'`, - ) - - expect(rows.length).toBe(0) - }, 30000) - }) - - // ─── jsonb_path_exists: boolean path queries ────────────────────── - - describe('jsonb_path_exists: boolean path queries', () => { - it('returns true for existing field (Extended)', async () => { - const plaintext = { role: 'pe-exists', extra: 'data' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE eql_v2.jsonb_path_exists(t.metadata, ${selectorTerm}::eql_v2_encrypted) - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('returns true for nested path (Extended)', async () => { - const plaintext = { - user: { email: 'pe-nested@test.com' }, - type: 'pe-nested', - } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.user.email', - 'steVecSelector', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE eql_v2.jsonb_path_exists(t.metadata, ${selectorTerm}::eql_v2_encrypted) - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('returns false for unknown path (Extended)', async () => { - const plaintext = { exists: true, marker: 'pe-nomatch' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.nonexistent.path', - 'steVecSelector', - ) - - const rows = await sql` - SELECT id, eql_v2.jsonb_path_exists(t.metadata, ${selectorTerm}::eql_v2_encrypted) as path_exists - FROM "protect-ci-jsonb" t - WHERE t.id = ${id} - ` - - expect(rows).toHaveLength(1) - expect(rows[0].path_exists).toBe(false) - }, 30000) - - it('returns true for existing field (Simple)', async () => { - const plaintext = { role: 'pe-exists-simple', extra: 'data' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE eql_v2.jsonb_path_exists(t.metadata, '${selectorTerm}'::eql_v2_encrypted) - AND test_run_id = '${TEST_RUN_ID}'`, - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('returns true for nested path (Simple)', async () => { - const plaintext = { - user: { email: 'pe-nested-simple@test.com' }, - type: 'pe-nested-simple', - } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.user.email', - 'steVecSelector', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE eql_v2.jsonb_path_exists(t.metadata, '${selectorTerm}'::eql_v2_encrypted) - AND test_run_id = '${TEST_RUN_ID}'`, - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('returns false for unknown path (Simple)', async () => { - const plaintext = { exists: true, marker: 'pe-nomatch-simple' } - await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.nonexistent.path', - 'steVecSelector', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE eql_v2.jsonb_path_exists(t.metadata, '${selectorTerm}'::eql_v2_encrypted) - AND test_run_id = '${TEST_RUN_ID}'`, - ) - - expect(rows.length).toBe(0) - }, 30000) - }) - - describe('jsonb_array_elements + jsonb_array_length: array queries', () => { - it('returns null length for missing path (Extended)', async () => { - const plaintext = { exists: true, marker: 'al-nomatch' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.nonexistent', - 'steVecSelector', - ) - - const rows = await sql` - SELECT t.id, - eql_v2.jsonb_array_length( - eql_v2.jsonb_path_query_first(t.metadata, ${selectorTerm}::eql_v2_encrypted) - ) as arr_len - FROM "protect-ci-jsonb" t - WHERE t.id = ${id} - ` - - expect(rows).toHaveLength(1) - expect(rows[0].arr_len).toBeNull() - - const dataRows = await sql` - SELECT (metadata).data as metadata FROM "protect-ci-jsonb" t WHERE t.id = ${id} - ` - expect(dataRows).toHaveLength(1) - await verifyRow(dataRows[0], plaintext) - }, 30000) - - // [@] notation (proxy convention) produces the selector hash matching is_array=true STE vec entries. - // EQL v2.3: walk the raw sv array on the jsonb payload so each element is a plain jsonb object - // (not the wrapped eql_v2_encrypted composite — its `.data` notation only works inside the function). - it('[@] selector matches is_array=true entries in STE vec', async () => { - const plaintext = { colors: ['a', 'b'], marker: 'diag-sv' } - const { id } = await insertRow(plaintext) - - const entries = await sql` - SELECT - elem->>'s' as selector, - (elem->>'a')::boolean as is_array - FROM "protect-ci-jsonb" t, - LATERAL jsonb_array_elements((t.metadata).data -> 'sv') AS elem - WHERE t.id = ${id} - ` - - const arrayEntries = entries.filter((e: any) => e.is_array === true) - expect(arrayEntries.length).toBeGreaterThan(0) - - const selectorAt = await encryptQueryTerm('$.colors[@]', 'steVecSelector') - const hashAt = await sql` - SELECT (${selectorAt}::eql_v2_encrypted).data->>'s' as s - ` - - expect(hashAt[0].s).toBe(arrayEntries[0].selector) - }, 30000) - - // EQL v2.3: `jsonb_path_query_first` returns at most one sv entry (LIMIT 1) — counting / expanding - // uses `jsonb_path_query`, which aggregates matching array entries into a single row whose `data` - // carries `sv: [...]` + `a: 1`. `jsonb_array_length` / `jsonb_array_elements` walk that inner sv. - it('returns correct length for known array (Extended)', async () => { - const plaintext = { colors: ['a', 'b', 'c', 'd'], marker: 'al-known' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.colors[@]', - 'steVecSelector', - ) - - const rows = await sql` - SELECT eql_v2.jsonb_array_length(elem) AS arr_len - FROM "protect-ci-jsonb" t, - LATERAL eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) AS elem - WHERE t.id = ${id} - ` - - expect(rows).toHaveLength(1) - expect(rows[0].arr_len).toBe(4) - - const dataRows = await sql` - SELECT (metadata).data as metadata FROM "protect-ci-jsonb" t WHERE t.id = ${id} - ` - expect(dataRows).toHaveLength(1) - await verifyRow(dataRows[0], plaintext) - }, 30000) - - it('returns correct length for known array (Simple)', async () => { - const plaintext = { colors: ['x', 'y', 'z'], marker: 'al-known-s' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.colors[@]', - 'steVecSelector', - ) - - const rows = await sql.unsafe( - `SELECT eql_v2.jsonb_array_length(elem) AS arr_len - FROM "protect-ci-jsonb" t, - LATERAL eql_v2.jsonb_path_query(t.metadata, $1::eql_v2_encrypted) AS elem - WHERE t.id = $2`, - [selectorTerm, id], - ) - - expect(rows).toHaveLength(1) - expect(rows[0].arr_len).toBe(3) - - const dataRows = await sql.unsafe( - `SELECT (metadata).data as metadata FROM "protect-ci-jsonb" t WHERE t.id = $1`, - [id], - ) - expect(dataRows).toHaveLength(1) - await verifyRow(dataRows[0], plaintext) - }, 30000) - - it('expands array via jsonb_array_elements (Extended)', async () => { - const plaintext = { tags: ['ae-a', 'ae-b', 'ae-c'], marker: 'ae-expand' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.tags[@]', 'steVecSelector') - - const rows = await sql` - SELECT eql_v2.jsonb_array_elements(elem) AS item - FROM "protect-ci-jsonb" t, - LATERAL eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) AS elem - WHERE t.id = ${id} - ` - - expect(rows).toHaveLength(3) - - const dataRows = await sql` - SELECT (metadata).data as metadata FROM "protect-ci-jsonb" t WHERE t.id = ${id} - ` - expect(dataRows).toHaveLength(1) - await verifyRow(dataRows[0], plaintext) - }, 30000) - - it('expands array via jsonb_array_elements (Simple)', async () => { - const plaintext = { - tags: ['ae-s-a', 'ae-s-b', 'ae-s-c'], - marker: 'ae-expand-s', - } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.tags[@]', 'steVecSelector') - - const rows = await sql.unsafe( - `SELECT eql_v2.jsonb_array_elements(elem) AS item - FROM "protect-ci-jsonb" t, - LATERAL eql_v2.jsonb_path_query(t.metadata, $1::eql_v2_encrypted) AS elem - WHERE t.id = $2`, - [selectorTerm, id], - ) - - expect(rows).toHaveLength(3) - - const dataRows = await sql.unsafe( - `SELECT (metadata).data as metadata FROM "protect-ci-jsonb" t WHERE t.id = $1`, - [id], - ) - expect(dataRows).toHaveLength(1) - await verifyRow(dataRows[0], plaintext) - }, 30000) - }) - - describe('containment: @> with array values', () => { - it('matches array subset (Extended)', async () => { - const plaintext = { - tags: ['ac-alpha', 'ac-beta', 'ac-gamma'], - marker: 'ac-subset', - } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { tags: ['ac-alpha'] }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.metadata @> ${containmentTerm}::eql_v2_encrypted - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('non-matching array value returns no rows (Extended)', async () => { - const plaintext = { tags: ['ac-exist'], marker: 'ac-nomatch' } - await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { tags: ['ac-nonexistent'] }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.metadata @> ${containmentTerm}::eql_v2_encrypted - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBe(0) - }, 30000) - - it('matches array subset (Simple)', async () => { - const plaintext = { - tags: ['ac-simple-x', 'ac-simple-y'], - marker: 'ac-simple', - } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { tags: ['ac-simple-x'] }, - 'steVecTerm', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.metadata @> $1::eql_v2_encrypted - AND t.test_run_id = $2`, - [containmentTerm, TEST_RUN_ID], - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('non-matching array value returns no rows (Simple)', async () => { - const plaintext = { tags: ['ac-s-exist'], marker: 'ac-s-nomatch' } - await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { tags: ['ac-s-absent'] }, - 'steVecTerm', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.metadata @> $1::eql_v2_encrypted - AND t.test_run_id = $2`, - [containmentTerm, TEST_RUN_ID], - ) - - expect(rows.length).toBe(0) - }, 30000) - - it('matches nested array subset (Extended)', async () => { - const plaintext = { - user: { roles: ['ac-nested-admin', 'ac-nested-editor'] }, - marker: 'ac-nested', - } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { user: { roles: ['ac-nested-admin'] } }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.metadata @> ${containmentTerm}::eql_v2_encrypted - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - }) - - describe('contained-by: <@ with array values', () => { - it('matches array superset (Extended)', async () => { - const plaintext = { - tags: ['cb-one', 'cb-two', 'cb-three'], - marker: 'cb-superset', - } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { tags: ['cb-one'] }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE ${containmentTerm}::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('non-matching array returns no rows (Extended)', async () => { - const plaintext = { tags: ['cb-exist'], marker: 'cb-nomatch' } - await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { tags: ['cb-absent'] }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE ${containmentTerm}::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBe(0) - }, 30000) - - it('matches array superset (Simple)', async () => { - const plaintext = { tags: ['cb-s-one', 'cb-s-two'], marker: 'cb-s-super' } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { tags: ['cb-s-one'] }, - 'steVecTerm', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE $1::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = $2`, - [containmentTerm, TEST_RUN_ID], - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('non-matching array returns no rows (Simple)', async () => { - const plaintext = { tags: ['cb-s-exist'], marker: 'cb-s-nomatch' } - await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { tags: ['cb-s-absent'] }, - 'steVecTerm', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE $1::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = $2`, - [containmentTerm, TEST_RUN_ID], - ) - - expect(rows.length).toBe(0) - }, 30000) - }) - - describe('storage: array round-trips (gaps only)', () => { - it('round-trips object with empty string array', async () => { - const plaintext = { tags: [], marker: 'rt-empty-string-arr' } - const { id } = await insertRow(plaintext) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.id = ${id} - ` - - expect(rows).toHaveLength(1) - await verifyRow(rows[0], plaintext) - }, 30000) - - it('round-trips nested empty object array', async () => { - const plaintext = { data: { items: [] }, marker: 'rt-empty-obj-arr' } - const { id } = await insertRow(plaintext) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.id = ${id} - ` - - expect(rows).toHaveLength(1) - await verifyRow(rows[0], plaintext) - }, 30000) - }) - - // ─── Containment: operand and protocol matrix ────────────────────── - - describe('containment: operand and protocol matrix', () => { - it('@> matches key/value (Simple)', async () => { - const plaintext = { role: 'cm-admin-s', dept: 'cm-eng-s' } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { role: 'cm-admin-s' }, - 'steVecTerm', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.metadata @> $1::eql_v2_encrypted - AND t.test_run_id = $2`, - [containmentTerm, TEST_RUN_ID], - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('@> non-matching returns no rows (Simple)', async () => { - const plaintext = { role: 'cm-exist-s' } - await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { role: 'cm-nope-s' }, - 'steVecTerm', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.metadata @> $1::eql_v2_encrypted - AND t.test_run_id = $2`, - [containmentTerm, TEST_RUN_ID], - ) - - expect(rows.length).toBe(0) - }, 30000) - - it('term <@ column matches subset (Extended)', async () => { - const plaintext = { role: 'cm-sub', marker: 'cm-sub-marker' } - const { id } = await insertRow(plaintext) - - // Query term is a SUBSET of the stored data - const containmentTerm = await encryptQueryTerm( - { role: 'cm-sub' }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE ${containmentTerm}::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('term <@ column non-matching (Extended)', async () => { - const plaintext = { role: 'cm-sub-x' } - await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { role: 'cm-sub-miss' }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE ${containmentTerm}::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBe(0) - }, 30000) - - it('term <@ column matches subset (Simple)', async () => { - const plaintext = { role: 'cm-sub-s', marker: 'cm-sub-s-marker' } - const { id } = await insertRow(plaintext) - - // Query term is a SUBSET of the stored data - const containmentTerm = await encryptQueryTerm( - { role: 'cm-sub-s' }, - 'steVecTerm', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE $1::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = $2`, - [containmentTerm, TEST_RUN_ID], - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - }) - - // ─── Field access: -> operator ───────────────────────────────────── - - describe('field access: -> operator', () => { - it('extracts field by encrypted selector (Extended)', async () => { - const plaintext = { - role: 'fa-enc', - dept: 'fa-dept', - marker: 'fa-enc-sel', - } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql` - SELECT t.metadata -> ${selectorTerm}::eql_v2_encrypted as extracted - FROM "protect-ci-jsonb" t - WHERE t.id = ${id} - ` - - expect(rows).toHaveLength(1) - expect(rows[0].extracted).not.toBeNull() - - const fullRows = await sql` - SELECT (metadata).data as metadata FROM "protect-ci-jsonb" t WHERE t.id = ${id} - ` - await verifyRow(fullRows[0], plaintext) - }, 30000) - - it('extracts field by encrypted selector (Simple)', async () => { - const plaintext = { - role: 'fa-enc-s', - dept: 'fa-dept-s', - marker: 'fa-enc-sel-s', - } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql.unsafe( - `SELECT t.metadata -> $1::eql_v2_encrypted as extracted - FROM "protect-ci-jsonb" t - WHERE t.id = $2`, - [selectorTerm, id], - ) - - expect(rows).toHaveLength(1) - expect(rows[0].extracted).not.toBeNull() - - const fullRows = await sql` - SELECT (metadata).data as metadata FROM "protect-ci-jsonb" t WHERE t.id = ${id} - ` - await verifyRow(fullRows[0], plaintext) - }, 30000) - - it('returns null for non-existent field (Extended)', async () => { - const plaintext = { role: 'fa-null', marker: 'fa-null-marker' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.nonexistent', - 'steVecSelector', - ) - - const rows = await sql` - SELECT t.metadata -> ${selectorTerm}::eql_v2_encrypted as extracted - FROM "protect-ci-jsonb" t - WHERE t.id = ${id} - ` - - expect(rows).toHaveLength(1) - expect(rows[0].extracted).toBeNull() - }, 30000) - - it('extracted field can be round-tripped (Extended)', async () => { - const plaintext = { - role: 'fa-roundtrip', - dept: 'fa-rt-dept', - marker: 'fa-rt-marker', - } - const { id } = await insertRow(plaintext) - - // Extract the role field via -> operator - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql` - SELECT t.metadata -> ${selectorTerm}::eql_v2_encrypted as extracted, - (t.metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.id = ${id} - ` - - expect(rows).toHaveLength(1) - expect(rows[0].extracted).not.toBeNull() - - // Decrypt the full document and verify the extracted field matches - await verifyRow(rows[0], plaintext) - }, 30000) - }) - - // ─── WHERE comparison: = equality ────────────────────────────────── - - // EQL v2.3: `=` on `eql_v2_encrypted` reduces to `hmac_256(a) = hmac_256(b)` and silently - // returns 0 rows when either side lacks `hm` (previously raised). For sv-element equality on - // oc-bearing selectors (strings / numbers), cast the extracted entry to `eql_v2.ste_vec_entry` - // — that operator is XOR-aware over `hm`/`oc` via `eq_term`. - describe('WHERE comparison: = equality (sv-element via ste_vec_entry)', () => { - it('jsonb_path_query_first = self-comparison (Extended)', async () => { - const plaintext = { role: 'eq-jpqf', marker: 'eq-jpqf-marker' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE (eql_v2.jsonb_path_query_first(t.metadata, ${selectorTerm}::eql_v2_encrypted)).data::eql_v2.ste_vec_entry - = (eql_v2.jsonb_path_query_first(t.metadata, ${selectorTerm}::eql_v2_encrypted)).data::eql_v2.ste_vec_entry - AND t.id = ${id} - ` - - expect(rows).toHaveLength(1) - await verifyRow(rows[0], plaintext) - }, 30000) - - it('jsonb_path_query_first = self-comparison (Simple)', async () => { - const plaintext = { role: 'eq-jpqf-s', marker: 'eq-jpqf-s-marker' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE (eql_v2.jsonb_path_query_first(t.metadata, $1::eql_v2_encrypted)).data::eql_v2.ste_vec_entry - = (eql_v2.jsonb_path_query_first(t.metadata, $1::eql_v2_encrypted)).data::eql_v2.ste_vec_entry - AND t.id = $2`, - [selectorTerm, id], - ) - - expect(rows).toHaveLength(1) - await verifyRow(rows[0], plaintext) - }, 30000) - - it('equality across two documents with same plaintext matches both', async () => { - const doc1 = { role: 'eq-cross-same', dept: 'eq-cross-d1' } - const doc2 = { role: 'eq-cross-same', dept: 'eq-cross-d2' } - - const { id: id1 } = await insertRow(doc1) - const { id: id2 } = await insertRow(doc2) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql` - SELECT a.id as id_a, b.id as id_b - FROM "protect-ci-jsonb" a, "protect-ci-jsonb" b - WHERE (eql_v2.jsonb_path_query_first(a.metadata, ${selectorTerm}::eql_v2_encrypted)).data::eql_v2.ste_vec_entry - = (eql_v2.jsonb_path_query_first(b.metadata, ${selectorTerm}::eql_v2_encrypted)).data::eql_v2.ste_vec_entry - AND a.id = ${id1} - AND b.id = ${id2} - ` - - expect(rows).toHaveLength(1) - expect(rows[0]).toMatchObject({ id_a: id1, id_b: id2 }) - }, 30000) - - it('equality across two documents with different plaintext returns empty', async () => { - const doc1 = { role: 'eq-cross-mismatch-1', marker: 'eq-mm-1' } - const doc2 = { role: 'eq-cross-mismatch-2', marker: 'eq-mm-2' } - - const { id: id1 } = await insertRow(doc1) - const { id: id2 } = await insertRow(doc2) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql` - SELECT a.id as id_a, b.id as id_b - FROM "protect-ci-jsonb" a, "protect-ci-jsonb" b - WHERE (eql_v2.jsonb_path_query_first(a.metadata, ${selectorTerm}::eql_v2_encrypted)).data::eql_v2.ste_vec_entry - = (eql_v2.jsonb_path_query_first(b.metadata, ${selectorTerm}::eql_v2_encrypted)).data::eql_v2.ste_vec_entry - AND a.id = ${id1} - AND b.id = ${id2} - ` - - expect(rows).toHaveLength(0) - }, 30000) - }) - - // ─── eql (default) return type ────────────────────────────────────── - - describe('eql (default) return type', () => { - it('selector query using raw eql return type', async () => { - const plaintext = { - user: { email: 'eql-raw-sel@test.com' }, - marker: 'eql-raw-sel', - } - const { id } = await insertRow(plaintext) - - // Omit returnType — single-value encryptQuery returns raw Encrypted object - const queryResult = await protectClient.encryptQuery('$.user.email', { - column: table.metadata, - table: table, - queryType: 'steVecSelector', - }) - if (queryResult.failure) throw new Error(queryResult.failure.message) - const rawResult = queryResult.data - - // Must use sql.json() to pass raw Encrypted object to PG - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${sql.json(rawResult)}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('containment query using raw eql return type', async () => { - const plaintext = { role: 'eql-raw-contain', marker: 'eql-raw-ct' } - const { id } = await insertRow(plaintext) - - // Omit returnType — single-value encryptQuery returns raw Encrypted object - const queryResult = await protectClient.encryptQuery( - { role: 'eql-raw-contain' }, - { - column: table.metadata, - table: table, - queryType: 'steVecTerm', - }, - ) - if (queryResult.failure) throw new Error(queryResult.failure.message) - const rawResult = queryResult.data - - // Must use sql.json() to pass raw Encrypted object to PG - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" - WHERE metadata @> ${sql.json(rawResult)}::eql_v2_encrypted - AND test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - }) - - // ─── Concurrent encrypt + decrypt stress ──────────────────────────── - - describe('concurrent encrypt + decrypt stress', () => { - it('concurrent encrypt + decrypt stress (10 parallel)', async () => { - const docs = Array.from({ length: 10 }, (_, i) => ({ - user: { email: `stress-${i}@test.com` }, - role: `stress-role-${i}`, - index: i, - marker: `stress-${i}`, - })) - - // Insert all 10 docs - const insertedIds: number[] = [] - for (const plaintext of docs) { - const { id } = await insertRow(plaintext) - insertedIds.push(id) - } - - // 10 parallel encrypt-query-decrypt pipelines - const results = await Promise.all( - docs.map(async (plaintext, i) => { - // Encrypt a selector query - const selectorTerm = await encryptQueryTerm( - '$.user.email', - 'steVecSelector', - ) - - // Query PG - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result - WHERE t.id = ${insertedIds[i]} - ` - - expect(rows).toHaveLength(1) - - // Decrypt - const decrypted = await protectClient.decryptModel({ - metadata: rows[0].metadata, - }) - if (decrypted.failure) throw new Error(decrypted.failure.message) - - return decrypted.data.metadata - }), - ) - - // Assert all 10 return correct plaintext - expect(results).toHaveLength(10) - results.forEach((result, i) => { - expect(result).toEqual(docs[i]) - }) - }, 120000) - }) -}) diff --git a/packages/protect/__tests__/supabase.test.ts b/packages/protect/__tests__/supabase.test.ts deleted file mode 100644 index 8face3bd5..000000000 --- a/packages/protect/__tests__/supabase.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable } from '@cipherstash/schema' -import { createClient } from '@supabase/supabase-js' -import postgres from 'postgres' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { - bulkModelsToEncryptedPgComposites, - type Encrypted, - encryptedToPgComposite, - isEncryptedPayload, - modelToEncryptedPgComposites, - protect, -} from '../src' - -// supabase.test.ts needs a live Supabase project, so the suite is skipped -// when the Supabase environment is not configured (e.g. in CI, pending a -// containerised Supabase setup). It runs locally when SUPABASE_URL, -// SUPABASE_ANON_KEY, and DATABASE_URL are all set. -const SUPABASE_ENABLED = Boolean( - process.env.SUPABASE_URL && - process.env.SUPABASE_ANON_KEY && - process.env.DATABASE_URL, -) - -const supabase = SUPABASE_ENABLED - ? createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!) - : (undefined as unknown as ReturnType) - -const table = csTable('protect-ci', { - encrypted: csColumn('encrypted').freeTextSearch().equality(), - age: csColumn('age').dataType('number').equality(), - score: csColumn('score').dataType('number').equality(), -}) - -// Hard code this as the CI database doesn't support order by on encrypted columns -const SKIP_ORDER_BY_TEST = true - -// Unique identifier for this test run to isolate data from concurrent test runs -// This is stored in a dedicated test_run_id column to avoid polluting test data -const TEST_RUN_ID = `test-run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - -// Track all inserted IDs for cleanup -const insertedIds: number[] = [] - -beforeAll(async () => { - // Idempotent fixture setup. The `protect-ci` table is shared across the - // drizzle + protect/supabase + stack/supabase integration suites; each - // suite's beforeAll runs the same CREATE TABLE so a fresh database is - // ready without manual DBA work. The schema is the union of every - // column those suites read or write. - const sql = postgres(process.env.DATABASE_URL as string, { prepare: false }) - try { - await sql` - CREATE TABLE IF NOT EXISTS "protect-ci" ( - id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - email eql_v2_encrypted, - age eql_v2_encrypted, - score eql_v2_encrypted, - profile eql_v2_encrypted, - encrypted eql_v2_encrypted, - "otherField" TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - test_run_id TEXT - ) - ` - // Backfill any column added after the table was first created on a - // long-lived CI database. CREATE TABLE IF NOT EXISTS is a no-op on - // those, so new columns need an explicit ADD COLUMN IF NOT EXISTS. - await sql` - ALTER TABLE "protect-ci" ADD COLUMN IF NOT EXISTS "otherField" TEXT - ` - // Tell PostgREST to refresh its schema cache so the supabase-js client - // can see a freshly created table without waiting for the polling - // interval. No-op on plain Postgres (no listener bound). - await sql`NOTIFY pgrst, 'reload schema'` - } finally { - await sql.end() - } - - // Clean up any data from this specific test run (safe for concurrent runs) - const { error } = await supabase - .from('protect-ci') - .delete() - .eq('test_run_id', TEST_RUN_ID) - - if (error) { - console.warn(`[protect]: Failed to clean up test data: ${error.message}`) - } -}, 30000) - -afterAll(async () => { - // Clean up all data from this test run - if (insertedIds.length > 0) { - const { error } = await supabase - .from('protect-ci') - .delete() - .in('id', insertedIds) - if (error) { - console.error(`[protect]: Failed to clean up test data: ${error.message}`) - } - } -}) - -describe.skipIf(!SUPABASE_ENABLED)('supabase', () => { - it('should insert and select encrypted data', async () => { - const protectClient = await protect({ schemas: [table] }) - - const e = 'hello world' - - const ciphertext = await protectClient.encrypt(e, { - column: table.encrypted, - table: table, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - const { data: insertedData, error: insertError } = await supabase - .from('protect-ci') - .insert({ - encrypted: encryptedToPgComposite(ciphertext.data), - test_run_id: TEST_RUN_ID, - }) - .select('id') - - if (insertError) { - throw new Error(`[protect]: ${insertError.message}`) - } - - insertedIds.push(insertedData[0].id) - - const { data, error } = await supabase - .from('protect-ci') - .select('id, encrypted::jsonb') - .eq('id', insertedData[0].id) - - if (error) { - throw new Error(`[protect]: ${error.message}`) - } - - const dataToDecrypt = data[0].encrypted as Encrypted - const plaintext = await protectClient.decrypt(dataToDecrypt) - - expect(plaintext).toEqual({ - data: e, - }) - }, 30000) - - it('should insert and select encrypted model data', async () => { - const protectClient = await protect({ schemas: [table] }) - - const model = { - encrypted: 'hello world', - otherField: 'not encrypted', - } - - const encryptedModel = await protectClient.encryptModel(model, table) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - const { data: insertedData, error: insertError } = await supabase - .from('protect-ci') - .insert([ - { - ...modelToEncryptedPgComposites(encryptedModel.data), - test_run_id: TEST_RUN_ID, - }, - ]) - .select('id') - - if (insertError) { - throw new Error(`[protect]: ${insertError.message}`) - } - - insertedIds.push(insertedData[0].id) - - const { data, error } = await supabase - .from('protect-ci') - .select('id, encrypted::jsonb, otherField') - .eq('id', insertedData[0].id) - - if (error) { - throw new Error(`[protect]: ${error.message}`) - } - - if (!isEncryptedPayload(data[0].encrypted)) { - throw new Error('Expected encrypted payload') - } - - const decryptedModel = await protectClient.decryptModel(data[0]) - - if (decryptedModel.failure) { - throw new Error(`[protect]: ${decryptedModel.failure.message}`) - } - - expect({ - encrypted: decryptedModel.data.encrypted, - otherField: data[0].otherField, - }).toEqual(model) - }, 30000) - - it('should insert and select bulk encrypted model data', async () => { - const protectClient = await protect({ schemas: [table] }) - - const models = [ - { - encrypted: 'hello world 1', - otherField: 'not encrypted 1', - }, - { - encrypted: 'hello world 2', - otherField: 'not encrypted 2', - }, - ] - - const encryptedModels = await protectClient.bulkEncryptModels(models, table) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - const dataToInsert = bulkModelsToEncryptedPgComposites( - encryptedModels.data, - ).map((row) => ({ - ...row, - test_run_id: TEST_RUN_ID, - })) - - const { data: insertedData, error: insertError } = await supabase - .from('protect-ci') - .insert(dataToInsert) - .select('id') - - if (insertError) { - throw new Error(`[protect]: ${insertError.message}`) - } - - insertedIds.push(...insertedData.map((d: { id: number }) => d.id)) - - const { data, error } = await supabase - .from('protect-ci') - .select('id, encrypted::jsonb, otherField') - .in( - 'id', - insertedData.map((d: { id: number }) => d.id), - ) - - if (error) { - throw new Error(`[protect]: ${error.message}`) - } - - const decryptedModels = await protectClient.bulkDecryptModels(data) - - if (decryptedModels.failure) { - throw new Error(`[protect]: ${decryptedModels.failure.message}`) - } - - expect( - decryptedModels.data.map((d) => { - return { - encrypted: d.encrypted, - otherField: d.otherField, - } - }), - ).toEqual(models) - }, 30000) - - it('should insert and query encrypted number data with equality', async () => { - const protectClient = await protect({ schemas: [table] }) - - const testAge = 25 - const model = { - age: testAge, - otherField: 'not encrypted', - } - - const encryptedModel = await protectClient.encryptModel(model, table) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - const insertResult = await supabase - .from('protect-ci') - .insert([ - { - ...modelToEncryptedPgComposites(encryptedModel.data), - test_run_id: TEST_RUN_ID, - }, - ]) - .select('id') - - if (insertResult.error) { - throw new Error(`[protect]: ${insertResult.error.message}`) - } - - const insertedRecordId = insertResult.data[0].id - insertedIds.push(insertedRecordId) - - // Create encrypted query for equality search with composite-literal returnType - const encryptedResult = await protectClient.encryptQuery([ - { - value: testAge, - column: table.age, - table: table, - queryType: 'equality', - returnType: 'composite-literal', - }, - ]) - - if (encryptedResult.failure) { - throw new Error(`[protect]: ${encryptedResult.failure.message}`) - } - - const [searchTerm] = encryptedResult.data - - // Query filtering by both encrypted age AND our specific test run's ID - // This ensures we don't pick up stale data from other test runs - const { data, error } = await supabase - .from('protect-ci') - .select('id, age::jsonb, otherField') - .eq('age', searchTerm) - .eq('test_run_id', TEST_RUN_ID) - - if (error) { - throw new Error(`[protect]: ${error.message}`) - } - - // Verify we found our specific row with encrypted age match - expect(data).toHaveLength(1) - - const decryptedModel = await protectClient.decryptModel(data[0]) - - if (decryptedModel.failure) { - throw new Error(`[protect]: ${decryptedModel.failure.message}`) - } - - expect(decryptedModel.data.age).toBe(testAge) - }, 30000) -}) diff --git a/packages/protect/package.json b/packages/protect/package.json deleted file mode 100644 index 2864adee6..000000000 --- a/packages/protect/package.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "name": "@cipherstash/protect", - "version": "12.0.2-rc.0", - "description": "CipherStash Protect for JavaScript", - "keywords": [ - "encrypted", - "query", - "language", - "typescript", - "ts", - "protect" - ], - "bugs": { - "url": "https://github.com/cipherstash/stack/issues" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/cipherstash/stack.git", - "directory": "packages/protect" - }, - "license": "MIT", - "author": "CipherStash ", - "type": "module", - "bin": { - "stash": "./dist/bin/stash.js" - }, - "main": "./dist/index.cjs", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" - }, - "./client": { - "types": "./dist/client.d.ts", - "import": "./dist/client.js", - "require": "./dist/client.cjs" - }, - "./identify": { - "types": "./dist/identify/index.d.ts", - "import": "./dist/identify/index.js", - "require": "./dist/identify/index.cjs" - }, - "./stash": { - "types": "./dist/stash/index.d.ts", - "import": "./dist/stash/index.js", - "require": "./dist/stash/index.cjs" - } - }, - "scripts": { - "build": "tsup", - "postbuild": "chmod +x ./dist/bin/stash.js", - "dev": "tsup --watch", - "test": "vitest run", - "release": "tsup" - }, - "devDependencies": { - "@supabase/supabase-js": "^2.110.2", - "execa": "^9.5.2", - "json-schema-to-typescript": "^15.0.2", - "postgres": "^3.4.7", - "tsup": "catalog:repo", - "tsx": "catalog:repo", - "typescript": "catalog:repo", - "vitest": "catalog:repo" - }, - "publishConfig": { - "access": "public" - }, - "dependencies": { - "@byteslice/result": "^0.2.0", - "@cipherstash/protect-ffi": "0.23.0", - "@cipherstash/schema": "workspace:*", - "@stricli/core": "^1.2.9", - "dotenv": "17.4.2", - "zod": "^3.25.76" - }, - "optionalDependencies": { - "@rollup/rollup-linux-x64-gnu": "4.62.2" - } -} diff --git a/packages/protect/src/bin/runner.ts b/packages/protect/src/bin/runner.ts deleted file mode 100644 index c313f5c5e..000000000 --- a/packages/protect/src/bin/runner.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { existsSync } from 'node:fs' -import { resolve } from 'node:path' - -type Pm = 'npm' | 'pnpm' | 'yarn' | 'bun' - -function fromUserAgent(): Pm | undefined { - const ua = process.env.npm_config_user_agent ?? '' - if (ua.startsWith('bun/')) return 'bun' - if (ua.startsWith('pnpm/')) return 'pnpm' - if (ua.startsWith('yarn/')) return 'yarn' - return undefined -} - -function fromLockfile(cwd: string): Pm | undefined { - if ( - existsSync(resolve(cwd, 'bun.lockb')) || - existsSync(resolve(cwd, 'bun.lock')) - ) - return 'bun' - if (existsSync(resolve(cwd, 'pnpm-lock.yaml'))) return 'pnpm' - if (existsSync(resolve(cwd, 'yarn.lock'))) return 'yarn' - if (existsSync(resolve(cwd, 'package-lock.json'))) return 'npm' - return undefined -} - -export function detectRunner(): string { - const pm = fromUserAgent() ?? fromLockfile(process.cwd()) ?? 'npm' - return pm === 'bun' - ? 'bunx' - : pm === 'pnpm' - ? 'pnpm dlx' - : pm === 'yarn' - ? 'yarn dlx' - : 'npx' -} diff --git a/packages/protect/src/bin/stash.ts b/packages/protect/src/bin/stash.ts deleted file mode 100644 index 7506cbae6..000000000 --- a/packages/protect/src/bin/stash.ts +++ /dev/null @@ -1,502 +0,0 @@ -import { config } from 'dotenv' - -config() - -import readline from 'node:readline' -import { - buildApplication, - buildCommand, - buildRouteMap, - run, -} from '@stricli/core' -import { Stash } from '../stash/index.js' -import { detectRunner } from './runner.js' - -// ANSI color codes for beautiful terminal output -const colors = { - reset: '\x1b[0m', - bold: '\x1b[1m', - dim: '\x1b[2m', - green: '\x1b[32m', - red: '\x1b[31m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - cyan: '\x1b[36m', - magenta: '\x1b[35m', -} - -const style = { - success: (text: string) => - `${colors.green}${colors.bold}✓${colors.reset} ${colors.green}${text}${colors.reset}`, - error: (text: string) => - `${colors.red}${colors.bold}✗${colors.reset} ${colors.red}${text}${colors.reset}`, - info: (text: string) => - `${colors.blue}${colors.bold}ℹ${colors.reset} ${colors.blue}${text}${colors.reset}`, - warning: (text: string) => - `${colors.yellow}${colors.bold}⚠${colors.reset} ${colors.yellow}${text}${colors.reset}`, - title: (text: string) => `${colors.bold}${colors.cyan}${text}${colors.reset}`, - label: (text: string) => `${colors.dim}${text}${colors.reset}`, - value: (text: string) => `${colors.bold}${text}${colors.reset}`, - bullet: () => `${colors.green}•${colors.reset}`, -} - -// Detect the package manager and build the CLI reference -const runner = detectRunner() -const cliRef = `${runner} stash` - -/** - * Get configuration from environment variables - */ -function getConfig(environment: string): Stash['config'] { - const workspaceCRN = process.env.CS_WORKSPACE_CRN - const clientId = process.env.CS_CLIENT_ID - const clientKey = process.env.CS_CLIENT_KEY - const apiKey = process.env.CS_CLIENT_ACCESS_KEY - const accessKey = process.env.CS_ACCESS_KEY - - const missing: string[] = [] - if (!workspaceCRN) missing.push('CS_WORKSPACE_CRN') - if (!clientId) missing.push('CS_CLIENT_ID') - if (!clientKey) missing.push('CS_CLIENT_KEY') - if (!apiKey) missing.push('CS_CLIENT_ACCESS_KEY') - - if (missing.length > 0) { - console.error( - style.error( - `Missing required environment variables: ${missing.join(', ')}`, - ), - ) - console.error( - `\n${style.info('Please set the following environment variables:')}`, - ) - for (const varName of missing) { - console.error(` ${style.bullet()} ${varName}`) - } - process.exit(1) - } - - if (!workspaceCRN || !clientId || !clientKey || !apiKey) { - // This should never happen due to the check above, but TypeScript needs it - throw new Error('Missing required configuration') - } - - return { - workspaceCRN, - clientId, - clientKey, - apiKey, - accessKey, - environment, - } -} - -/** - * Create a Stash instance with proper error handling - */ -function createStash(environment: string): Stash { - const config = getConfig(environment) - return new Stash(config) -} - -/** - * Prompt user for confirmation - */ -function askConfirmation(prompt: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) - - return new Promise((resolve) => { - rl.question(prompt, (answer) => { - rl.close() - const normalized = answer.trim().toLowerCase() - resolve(normalized === 'y' || normalized === 'yes') - }) - }) -} - -/** - * Set command - Store an encrypted secret - */ -const setCommand = buildCommand({ - func: async (flags: { name: string; value: string; environment: string }) => { - const { name, value, environment } = flags - const stash = createStash(environment) - - console.log( - `${style.info(`Encrypting and storing secret "${name}" in environment "${environment}"...`)}`, - ) - - const result = await stash.set(name, value) - if (result.failure) { - console.error( - style.error(`Failed to set secret: ${result.failure.message}`), - ) - process.exit(1) - } - - console.log( - style.success( - `Secret "${name}" stored successfully in environment "${environment}"`, - ), - ) - }, - parameters: { - flags: { - name: { - kind: 'parsed', - parse: String, - brief: 'Name of the secret to store', - }, - value: { - kind: 'parsed', - parse: String, - brief: 'Plaintext value to encrypt and store', - }, - environment: { - kind: 'parsed', - parse: String, - brief: 'Environment name (e.g., production, staging, development)', - }, - }, - aliases: { - n: 'name', - V: 'value', - e: 'environment', - }, - }, - docs: { - brief: 'Store an encrypted secret in CipherStash', - fullDescription: ` -Store a secret value that will be encrypted locally before being sent to the CipherStash API. -The secret is encrypted end-to-end, ensuring your plaintext never leaves your machine unencrypted. - -Examples: - ${cliRef} secrets set --name DATABASE_URL --value "postgres://..." --environment production - ${cliRef} secrets set -n DATABASE_URL -V "postgres://..." -e production - ${cliRef} secrets set --name API_KEY --value "sk-123..." --environment staging - `.trim(), - }, -}) - -/** - * Get command - Retrieve and decrypt a secret - */ -const getCommand = buildCommand({ - func: async (flags: { name: string; environment: string }) => { - const { name, environment } = flags - const stash = createStash(environment) - - console.log( - `${style.info(`Retrieving secret "${name}" from environment "${environment}"...`)}`, - ) - - const result = await stash.get(name) - if (result.failure) { - console.error( - style.error(`Failed to get secret: ${result.failure.message}`), - ) - process.exit(1) - } - - console.log(`\n${style.title('Secret Value:')}`) - console.log(style.value(result.data)) - }, - parameters: { - flags: { - name: { - kind: 'parsed', - parse: String, - brief: 'Name of the secret to retrieve', - }, - environment: { - kind: 'parsed', - parse: String, - brief: 'Environment name (e.g., production, staging, development)', - }, - }, - aliases: { - n: 'name', - e: 'environment', - }, - }, - docs: { - brief: 'Retrieve and decrypt a secret from CipherStash', - fullDescription: ` -Retrieve a secret from CipherStash and decrypt it locally. The secret value is decrypted -on your machine, ensuring end-to-end security. - -Examples: - ${cliRef} secrets get --name DATABASE_URL --environment production - ${cliRef} secrets get -n DATABASE_URL -e production - ${cliRef} secrets get --name API_KEY --environment staging - `.trim(), - }, -}) - -/** - * List command - List all secrets in an environment - */ -const listCommand = buildCommand({ - func: async (flags: { environment: string }) => { - const { environment } = flags - const stash = createStash(environment) - - console.log( - `${style.info(`Listing secrets in environment "${environment}"...`)}`, - ) - - const result = await stash.list() - if (result.failure) { - console.error( - style.error(`Failed to list secrets: ${result.failure.message}`), - ) - process.exit(1) - } - - if (result.data.length === 0) { - console.log( - `\n${style.warning(`No secrets found in environment "${environment}"`)}`, - ) - return - } - - console.log(`\n${style.title(`Secrets in environment "${environment}":`)}`) - console.log('') - - for (const secret of result.data) { - const name = style.value(secret.name) - const metadata: string[] = [] - if (secret.createdAt) { - metadata.push( - `${style.label('created:')} ${new Date(secret.createdAt).toLocaleString()}`, - ) - } - if (secret.updatedAt) { - metadata.push( - `${style.label('updated:')} ${new Date(secret.updatedAt).toLocaleString()}`, - ) - } - - const metaStr = - metadata.length > 0 - ? ` ${colors.dim}(${metadata.join(', ')})${colors.reset}` - : '' - console.log(` ${style.bullet()} ${name}${metaStr}`) - } - - console.log('') - console.log( - style.label( - `Total: ${result.data.length} secret${result.data.length === 1 ? '' : 's'}`, - ), - ) - }, - parameters: { - flags: { - environment: { - kind: 'parsed', - parse: String, - brief: 'Environment name (e.g., production, staging, development)', - }, - }, - aliases: { - e: 'environment', - }, - }, - docs: { - brief: 'List all secrets in an environment', - fullDescription: ` -List all secrets stored in the specified environment. Only secret names and metadata -are returned; values remain encrypted and are not displayed. - -Examples: - ${cliRef} secrets list --environment production - ${cliRef} secrets list -e production - ${cliRef} secrets list --environment staging - `.trim(), - }, -}) - -/** - * Delete command - Delete a secret from the vault - */ -const deleteCommand = buildCommand({ - func: async (flags: { name: string; environment: string; yes?: boolean }) => { - const { name, environment, yes } = flags - const stash = createStash(environment) - - // Ask for confirmation unless --yes flag is set - if (!yes) { - const confirmation = await askConfirmation( - `${style.warning(`Are you sure you want to delete secret "${name}" from environment "${environment}"? This action cannot be undone. (yes/no): `)}`, - ) - - if (!confirmation) { - console.log(style.info('Deletion cancelled.')) - return - } - } - - console.log( - `${style.info(`Deleting secret "${name}" from environment "${environment}"...`)}`, - ) - - const result = await stash.delete(name) - if (result.failure) { - console.error( - style.error(`Failed to delete secret: ${result.failure.message}`), - ) - process.exit(1) - } - - console.log( - style.success( - `Secret "${name}" deleted successfully from environment "${environment}"`, - ), - ) - }, - parameters: { - flags: { - name: { - kind: 'parsed', - parse: String, - brief: 'Name of the secret to delete', - }, - environment: { - kind: 'parsed', - parse: String, - brief: 'Environment name (e.g., production, staging, development)', - }, - yes: { - kind: 'boolean', - optional: true, - brief: 'Skip confirmation prompt', - }, - }, - aliases: { - n: 'name', - e: 'environment', - y: 'yes', - }, - }, - docs: { - brief: 'Delete a secret from CipherStash', - fullDescription: ` -Permanently delete a secret from the specified environment. This action cannot be undone. -By default, you will be prompted for confirmation before deletion. Use --yes to skip the confirmation. - -Examples: - ${cliRef} secrets delete --name DATABASE_URL --environment production - ${cliRef} secrets delete -n DATABASE_URL -e production --yes - ${cliRef} secrets delete --name API_KEY --environment staging -y - `.trim(), - }, -}) - -/** - * Secrets route map - Groups all secret management commands - */ -const secretsRouteMap = buildRouteMap({ - routes: { - set: setCommand, - get: getCommand, - list: listCommand, - delete: deleteCommand, - }, - docs: { - brief: 'Manage encrypted secrets in CipherStash', - fullDescription: ` -The secrets command group provides operations for managing encrypted secrets stored in CipherStash. -All secrets are encrypted locally before being sent to the API, ensuring end-to-end encryption. - -Available Commands: - set Store an encrypted secret - get Retrieve and decrypt a secret - list List all secrets in an environment - delete Delete a secret from the vault - -Environment Variables: - CS_WORKSPACE_CRN CipherStash workspace CRN (required) - CS_CLIENT_ID CipherStash client ID (required) - CS_CLIENT_KEY CipherStash client key (required) - CS_CLIENT_ACCESS_KEY CipherStash client access key (required) - -Examples: - ${cliRef} secrets set --name DATABASE_URL --value "postgres://..." --environment production - ${cliRef} secrets set -n DATABASE_URL -V "postgres://..." -e production - ${cliRef} secrets get --name DATABASE_URL --environment production - ${cliRef} secrets get -n DATABASE_URL -e production - ${cliRef} secrets list --environment production - ${cliRef} secrets list -e production - ${cliRef} secrets delete --name DATABASE_URL --environment production - ${cliRef} secrets delete -n DATABASE_URL -e production --yes - ${cliRef} secrets delete -n DATABASE_URL -e production -y - `.trim(), - }, -}) - -/** - * Root command - Entry point for the CLI - */ -const rootRouteMap = buildRouteMap({ - routes: { - secrets: secretsRouteMap, - }, - docs: { - brief: 'CipherStash Protect - Encrypted secrets management', - fullDescription: ` -CipherStash Protect CLI - -Manage encrypted secrets with end-to-end encryption. Secrets are encrypted locally -before being sent to the CipherStash API, ensuring your plaintext never leaves -your machine unencrypted. - -Quick Start: - 1. Set required environment variables (CS_WORKSPACE_CRN, CS_CLIENT_ID, etc.) - 2. Use '${cliRef} secrets set' to store your first secret - 3. Use '${cliRef} secrets get' to retrieve secrets when needed - -Commands: - secrets Manage encrypted secrets - -Run '${cliRef} --help' for more information about a command. - `.trim(), - }, -}) - -/** - * Build the CLI application - */ -const app = buildApplication(rootRouteMap, { - name: 'stash', - versionInfo: { currentVersion: '10.2.1' }, - scanner: { caseStyle: 'allow-kebab-for-camel' }, -}) - -/** - * Main entry point - */ -async function main(): Promise { - try { - await run(app, process.argv.slice(2), { - process, - async forCommand() { - return { - process, - } - }, - }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - console.error(style.error(`Unexpected error: ${message}`)) - process.exit(1) - } -} - -void main().catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error) - console.error(style.error(`Fatal error: ${message}`)) - process.exit(1) -}) diff --git a/packages/protect/src/client.ts b/packages/protect/src/client.ts deleted file mode 100644 index 9d165ecc1..000000000 --- a/packages/protect/src/client.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Client-safe exports for @cipherstash/protect - * - * This entry point exports types and utilities that can be used in client-side code - * without requiring the @cipherstash/protect-ffi native module. - * - * Use this import path: `@cipherstash/protect/client` - */ - -export type { - ProtectColumn, - ProtectTable, - ProtectTableColumn, - ProtectValue, -} from '@cipherstash/schema' -// Schema types and utilities - client-safe -export { csColumn, csTable, csValue } from '@cipherstash/schema' -export type { ProtectClient } from './ffi' diff --git a/packages/protect/src/ffi/helpers/error-code.ts b/packages/protect/src/ffi/helpers/error-code.ts deleted file mode 100644 index 0552e84a1..000000000 --- a/packages/protect/src/ffi/helpers/error-code.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { - ProtectError as FfiProtectError, - type ProtectErrorCode, -} from '@cipherstash/protect-ffi' - -/** - * Extracts FFI error code from an error if it's an FFI error, otherwise returns undefined. - * Used to preserve specific error codes in ProtectError responses. - */ -export function getErrorCode(error: unknown): ProtectErrorCode | undefined { - return error instanceof FfiProtectError ? error.code : undefined -} diff --git a/packages/protect/src/ffi/helpers/infer-index-type.ts b/packages/protect/src/ffi/helpers/infer-index-type.ts deleted file mode 100644 index 3700ff166..000000000 --- a/packages/protect/src/ffi/helpers/infer-index-type.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { JsPlaintext, QueryOpName } from '@cipherstash/protect-ffi' -import type { ProtectColumn } from '@cipherstash/schema' -import type { FfiIndexTypeName, QueryTypeName } from '../../types' -import { queryTypeToFfi, queryTypeToQueryOp } from '../../types' - -/** - * Infer the primary index type from a column's configured indexes. - * Priority: unique > match > ore > ste_vec (for scalar queries) - */ -export function inferIndexType(column: ProtectColumn): FfiIndexTypeName { - const config = column.build() - const indexes = config.indexes - - if (!indexes || Object.keys(indexes).length === 0) { - throw new Error(`Column "${column.getName()}" has no indexes configured`) - } - - // Priority order for inference - if (indexes.unique) return 'unique' - if (indexes.match) return 'match' - if (indexes.ore) return 'ore' - if (indexes.ste_vec) return 'ste_vec' - - throw new Error( - `Column "${column.getName()}" has no suitable index for queries`, - ) -} - -/** - * Infer the FFI query operation from plaintext type for STE Vec queries. - * - String → ste_vec_selector (JSONPath queries like '$.user.email') - * - Object/Array/Number/Boolean → ste_vec_term (containment queries) - */ -export function inferQueryOpFromPlaintext(plaintext: JsPlaintext): QueryOpName { - if (typeof plaintext === 'string') { - return 'ste_vec_selector' - } - // Objects, arrays, numbers, booleans are all valid JSONB containment values - if ( - typeof plaintext === 'object' || - typeof plaintext === 'number' || - typeof plaintext === 'boolean' || - typeof plaintext === 'bigint' - ) { - return 'ste_vec_term' - } - // This should never happen with valid JsPlaintext, but keep for safety - return 'ste_vec_term' -} - -/** - * Validate that the specified index type is configured on the column - */ -export function validateIndexType( - column: ProtectColumn, - indexType: FfiIndexTypeName, -): void { - const config = column.build() - const indexes = config.indexes ?? {} - - const indexMap: Record = { - unique: !!indexes.unique, - match: !!indexes.match, - ore: !!indexes.ore, - ste_vec: !!indexes.ste_vec, - } - - if (!indexMap[indexType]) { - throw new Error( - `Index type "${indexType}" is not configured on column "${column.getName()}"`, - ) - } -} - -/** - * Resolve the index type and query operation for a query. - * Validates the index type is configured on the column when queryType is explicit. - * For ste_vec columns without explicit queryType, infers queryOp from plaintext shape. - * - * @param column - The column to resolve the index type for - * @param queryType - Optional explicit query type (if provided, validates against column config) - * @param plaintext - Optional plaintext value for queryOp inference on ste_vec columns - * @returns The FFI index type name and optional query operation name - * @throws Error if ste_vec is inferred but queryOp cannot be determined - */ -export function resolveIndexType( - column: ProtectColumn, - queryType?: QueryTypeName, - plaintext?: JsPlaintext | null, -): { indexType: FfiIndexTypeName; queryOp?: QueryOpName } { - const indexType = queryType - ? queryTypeToFfi[queryType] - : inferIndexType(column) - - if (queryType) { - validateIndexType(column, indexType) - - // For searchableJson, infer queryOp from plaintext type (not from mapping) - if (queryType === 'searchableJson') { - if (plaintext === undefined || plaintext === null) { - return { indexType } - } - return { indexType, queryOp: inferQueryOpFromPlaintext(plaintext) } - } - - return { indexType, queryOp: queryTypeToQueryOp[queryType] } - } - - // ste_vec inferred without explicit queryType → must infer from plaintext - if (indexType === 'ste_vec') { - if (plaintext === undefined || plaintext === null) { - // Null plaintext handled by caller (returns null early) - no inference needed - return { indexType } - } - return { indexType, queryOp: inferQueryOpFromPlaintext(plaintext) } - } - - // Non-ste_vec → no queryOp needed - return { indexType } -} diff --git a/packages/protect/src/ffi/helpers/type-guards.ts b/packages/protect/src/ffi/helpers/type-guards.ts deleted file mode 100644 index 86fb2fece..000000000 --- a/packages/protect/src/ffi/helpers/type-guards.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { ScalarQueryTerm } from '../../types' - -/** - * Type guard to check if a value is an array of ScalarQueryTerm objects. - * Used to discriminate between single value and bulk encryption in encryptQuery overloads. - */ -export function isScalarQueryTermArray( - value: unknown, -): value is readonly ScalarQueryTerm[] { - return ( - Array.isArray(value) && - value.length > 0 && - typeof value[0] === 'object' && - value[0] !== null && - 'column' in value[0] && - 'table' in value[0] - ) -} diff --git a/packages/protect/src/ffi/helpers/validation.ts b/packages/protect/src/ffi/helpers/validation.ts deleted file mode 100644 index d544bcf9c..000000000 --- a/packages/protect/src/ffi/helpers/validation.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { Result } from '@byteslice/result' -import { type ProtectError, ProtectErrorTypes } from '../..' -import type { FfiIndexTypeName } from '../../types' - -/** - * Validates that a value is not NaN or Infinity. - * Returns a failure Result if validation fails, undefined otherwise. - * Use this in async flows that return Result types. - * - * Uses `never` as the success type so the result can be assigned to any Result. - * - * @internal - */ -export function validateNumericValue( - value: unknown, -): Result | undefined { - if (typeof value === 'number' && Number.isNaN(value)) { - return { - failure: { - type: ProtectErrorTypes.EncryptionError, - message: '[protect]: Cannot encrypt NaN value', - }, - } - } - if (typeof value === 'number' && !Number.isFinite(value)) { - return { - failure: { - type: ProtectErrorTypes.EncryptionError, - message: '[protect]: Cannot encrypt Infinity value', - }, - } - } - return undefined -} - -/** - * Validates that a value is not NaN or Infinity. - * Throws an error if validation fails. - * Use this in sync flows where exceptions are caught. - * - * @internal - */ -export function assertValidNumericValue(value: unknown): void { - if (typeof value === 'number' && Number.isNaN(value)) { - throw new Error('[protect]: Cannot encrypt NaN value') - } - if (typeof value === 'number' && !Number.isFinite(value)) { - throw new Error('[protect]: Cannot encrypt Infinity value') - } -} - -/** - * Validates that the value type is compatible with the index type. - * Match index (freeTextSearch) only supports string values. - * Returns a failure Result if validation fails, undefined otherwise. - * Use this in async flows that return Result types. - * - * @internal - */ -export function validateValueIndexCompatibility( - value: unknown, - indexType: FfiIndexTypeName, - columnName: string, -): Result | undefined { - if (typeof value === 'number' && indexType === 'match') { - return { - failure: { - type: ProtectErrorTypes.EncryptionError, - message: `[protect]: Cannot use 'match' index with numeric value on column "${columnName}". The 'freeTextSearch' index only supports string values. Configure the column with 'orderAndRange()' or 'equality()' for numeric queries.`, - }, - } - } - return undefined -} - -/** - * Validates that the value type is compatible with the index type. - * Match index (freeTextSearch) only supports string values. - * Throws an error if validation fails. - * Use this in sync flows where exceptions are caught. - * - * @internal - */ -export function assertValueIndexCompatibility( - value: unknown, - indexType: FfiIndexTypeName, - columnName: string, -): void { - if (typeof value === 'number' && indexType === 'match') { - throw new Error( - `[protect]: Cannot use 'match' index with numeric value on column "${columnName}". The 'freeTextSearch' index only supports string values. Configure the column with 'orderAndRange()' or 'equality()' for numeric queries.`, - ) - } -} diff --git a/packages/protect/src/ffi/index.ts b/packages/protect/src/ffi/index.ts deleted file mode 100644 index 9c0505bc3..000000000 --- a/packages/protect/src/ffi/index.ts +++ /dev/null @@ -1,428 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import { type JsPlaintext, newClient } from '@cipherstash/protect-ffi' -import { - type EncryptConfig, - encryptConfigSchema, - type ProtectTable, - type ProtectTableColumn, -} from '@cipherstash/schema' -import { logger } from '../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '..' -import { toFfiKeysetIdentifier } from '../helpers' -import type { - BulkDecryptPayload, - BulkEncryptPayload, - Client, - Decrypted, - Encrypted, - EncryptOptions, - EncryptQueryOptions, - KeysetIdentifier, - ScalarQueryTerm, - SearchTerm, -} from '../types' -import { isScalarQueryTermArray } from './helpers/type-guards' -import { BatchEncryptQueryOperation } from './operations/batch-encrypt-query' -import { BulkDecryptOperation } from './operations/bulk-decrypt' -import { BulkDecryptModelsOperation } from './operations/bulk-decrypt-models' -import { BulkEncryptOperation } from './operations/bulk-encrypt' -import { BulkEncryptModelsOperation } from './operations/bulk-encrypt-models' -import { DecryptOperation } from './operations/decrypt' -import { DecryptModelOperation } from './operations/decrypt-model' -import { SearchTermsOperation } from './operations/deprecated/search-terms' -import { EncryptOperation } from './operations/encrypt' -import { EncryptModelOperation } from './operations/encrypt-model' -import { EncryptQueryOperation } from './operations/encrypt-query' - -export const noClientError = () => - new Error( - 'The EQL client has not been initialized. Please call init() before using the client.', - ) - -/** The ProtectClient is the main entry point for interacting with the CipherStash Protect.js library. - * It provides methods for encrypting and decrypting individual values, as well as models (objects) and bulk operations. - * - * The client must be initialized using the {@link protect} function before it can be used. - */ -export class ProtectClient { - private client: Client - private encryptConfig: EncryptConfig | undefined - - /** - * Initializes the ProtectClient with the provided configuration. - * @internal - * @param config - The configuration object for initializing the client. - * @returns A promise that resolves to a {@link Result} containing the initialized ProtectClient or a {@link ProtectError}. - **/ - async init(config: { - encryptConfig: EncryptConfig - workspaceCrn?: string - accessKey?: string - clientId?: string - clientKey?: string - keyset?: KeysetIdentifier - }): Promise> { - return await withResult( - async () => { - const validated: EncryptConfig = encryptConfigSchema.parse( - config.encryptConfig, - ) - - logger.debug( - 'Initializing the Protect.js client with the following encrypt config:', - { - encryptConfig: validated, - }, - ) - - // newClient handles env var fallback internally via withEnvCredentials, - // so we pass config values through without manual fallback here. - this.client = await newClient({ - encryptConfig: validated, - clientOpts: { - workspaceCrn: config.workspaceCrn, - accessKey: config.accessKey, - clientId: config.clientId, - clientKey: config.clientKey, - keyset: toFfiKeysetIdentifier(config.keyset), - }, - }) - - this.encryptConfig = validated - - logger.info('Successfully initialized the Protect.js client.') - return this - }, - (error: unknown) => ({ - type: ProtectErrorTypes.ClientInitError, - message: (error as Error).message, - }), - ) - } - - /** - * Encrypt a value - returns a promise which resolves to an encrypted value. - * - * @param plaintext - The plaintext value to be encrypted. Can be null. - * @param opts - Options specifying the column and table for encryption. - * @returns An EncryptOperation that can be awaited or chained with additional methods. - * - * @example - * The following example demonstrates how to encrypt a value using the Protect client. - * It includes defining an encryption schema with {@link csTable} and {@link csColumn}, - * initializing the client with {@link protect}, and performing the encryption. - * - * `encrypt` returns an {@link EncryptOperation} which can be awaited to get a {@link Result} - * which can either be the encrypted value or a {@link ProtectError}. - * - * ```typescript - * // Define encryption schema - * import { csTable, csColumn } from "@cipherstash/protect" - * const userSchema = csTable("users", { - * email: csColumn("email"), - * }); - * - * // Initialize Protect client - * const protectClient = await protect({ schemas: [userSchema] }) - * - * // Encrypt a value - * const encryptedResult = await protectClient.encrypt( - * "person@example.com", - * { column: userSchema.email, table: userSchema } - * ) - * - * // Handle encryption result - * if (encryptedResult.failure) { - * throw new Error(`Encryption failed: ${encryptedResult.failure.message}`); - * } - * - * console.log("Encrypted data:", encryptedResult.data); - * ``` - * - * @example - * When encrypting data, a {@link LockContext} can be provided to tie the encryption to a specific user or session. - * This ensures that the same lock context is required for decryption. - * - * The following example demonstrates how to create a lock context using a user's JWT token - * and use it during encryption. - * - * ```typescript - * // Define encryption schema and initialize client as above - * - * // Create a lock for the user's `sub` claim from their JWT - * const lc = new LockContext(); - * const lockContext = await lc.identify(userJwt); - * - * if (lockContext.failure) { - * // Handle the failure - * } - * - * // Encrypt a value with the lock context - * // Decryption will then require the same lock context - * const encryptedResult = await protectClient.encrypt( - * "person@example.com", - * { column: userSchema.email, table: userSchema } - * ) - * .withLockContext(lockContext) - * ``` - * - * @see {@link Result} - * @see {@link csTable} - * @see {@link LockContext} - * @see {@link EncryptOperation} - */ - encrypt( - plaintext: JsPlaintext | null, - opts: EncryptOptions, - ): EncryptOperation { - return new EncryptOperation(this.client, plaintext, opts) - } - - /** - * Encrypt a query value - returns a promise which resolves to an encrypted query value. - * - * @param plaintext - The plaintext value to be encrypted for querying. Can be null. - * @param opts - Options specifying the column, table, and optional queryType for encryption. - * @returns An EncryptQueryOperation that can be awaited or chained with additional methods. - * - * @example - * The following example demonstrates how to encrypt a query value using the Protect client. - * - * ```typescript - * // Define encryption schema - * import { csTable, csColumn } from "@cipherstash/protect" - * const userSchema = csTable("users", { - * email: csColumn("email").equality(), - * }); - * - * // Initialize Protect client - * const protectClient = await protect({ schemas: [userSchema] }) - * - * // Encrypt a query value - * const encryptedResult = await protectClient.encryptQuery( - * "person@example.com", - * { column: userSchema.email, table: userSchema, queryType: 'equality' } - * ) - * - * // Handle encryption result - * if (encryptedResult.failure) { - * throw new Error(`Encryption failed: ${encryptedResult.failure.message}`); - * } - * - * console.log("Encrypted query:", encryptedResult.data); - * ``` - * - * @example - * The queryType can be auto-inferred from the column's configured indexes: - * - * ```typescript - * // When queryType is omitted, it will be inferred from the column's indexes - * const encryptedResult = await protectClient.encryptQuery( - * "person@example.com", - * { column: userSchema.email, table: userSchema } - * ) - * ``` - * - * @see {@link EncryptQueryOperation} - * - * **JSONB columns (searchableJson):** - * When `queryType` is omitted on a `searchableJson()` column, the query operation is inferred: - * - String plaintext → `steVecSelector` (JSONPath queries like `'$.user.email'`) - * - Object/Array plaintext → `steVecTerm` (containment queries like `{ role: 'admin' }`) - */ - encryptQuery( - plaintext: JsPlaintext | null, - opts: EncryptQueryOptions, - ): EncryptQueryOperation - - /** - * Encrypt multiple values for use in queries (batch operation). - * @param terms - Array of query terms to encrypt - */ - encryptQuery(terms: readonly ScalarQueryTerm[]): BatchEncryptQueryOperation - - encryptQuery( - plaintextOrTerms: JsPlaintext | null | readonly ScalarQueryTerm[], - opts?: EncryptQueryOptions, - ): EncryptQueryOperation | BatchEncryptQueryOperation { - // Discriminate between ScalarQueryTerm[] and JsPlaintext (which can also be an array) - // using a type guard function - if (isScalarQueryTermArray(plaintextOrTerms)) { - return new BatchEncryptQueryOperation(this.client, plaintextOrTerms) - } - - // Handle empty arrays: if opts provided, treat as single value; otherwise batch mode - // This maintains backward compatibility for encryptQuery([]) while allowing - // encryptQuery([], opts) to encrypt an empty array as a single value - if ( - Array.isArray(plaintextOrTerms) && - plaintextOrTerms.length === 0 && - !opts - ) { - return new BatchEncryptQueryOperation( - this.client, - [] as readonly ScalarQueryTerm[], - ) - } - - return new EncryptQueryOperation( - this.client, - plaintextOrTerms as JsPlaintext | null, - opts!, - ) - } - - /** - * Decryption - returns a promise which resolves to a decrypted value. - * - * @param encryptedData - The encrypted data to be decrypted. - * @returns A DecryptOperation that can be awaited or chained with additional methods. - * - * @example - * The following example demonstrates how to decrypt a value that was previously encrypted using {@link encrypt} client. - * It includes encrypting a value first, then decrypting it, and handling the result. - * - * ```typescript - * const encryptedData = await eqlClient.encrypt( - * "person@example.com", - * { column: "email", table: "users" } - * ) - * const decryptResult = await eqlClient.decrypt(encryptedData) - * if (decryptResult.failure) { - * throw new Error(`Decryption failed: ${decryptResult.failure.message}`); - * } - * console.log("Decrypted data:", decryptResult.data); - * ``` - * - * @example - * Provide a lock context when decrypting: - * ```typescript - * await eqlClient.decrypt(encryptedData) - * .withLockContext(lockContext) - * ``` - * - * @see {@link LockContext} - * @see {@link DecryptOperation} - */ - decrypt(encryptedData: Encrypted): DecryptOperation { - return new DecryptOperation(this.client, encryptedData) - } - - /** - * Encrypt a model based on its encryptConfig. - * - * @example - * ```typescript - * type User = { - * id: string; - * email: string; // encrypted - * } - * - * // Define the schema for the users table - * const usersSchema = csTable('users', { - * email: csColumn('email').freeTextSearch().equality().orderAndRange(), - * }) - * - * // Initialize the Protect client - * const protectClient = await protect({ schemas: [usersSchema] }) - * - * // Encrypt a user model - * const encryptedModel = await protectClient.encryptModel( - * { id: 'user_123', email: 'person@example.com' }, - * usersSchema, - * ) - * ``` - */ - encryptModel>( - input: Decrypted, - table: ProtectTable, - ): EncryptModelOperation { - return new EncryptModelOperation(this.client, input, table) - } - - /** - * Decrypt a model with encrypted values - * Usage: - * await eqlClient.decryptModel(encryptedModel) - * await eqlClient.decryptModel(encryptedModel).withLockContext(lockContext) - */ - decryptModel>( - input: T, - ): DecryptModelOperation { - return new DecryptModelOperation(this.client, input) - } - - /** - * Bulk encrypt models with decrypted values - * Usage: - * await eqlClient.bulkEncryptModels(decryptedModels, table) - * await eqlClient.bulkEncryptModels(decryptedModels, table).withLockContext(lockContext) - */ - bulkEncryptModels>( - input: Array>, - table: ProtectTable, - ): BulkEncryptModelsOperation { - return new BulkEncryptModelsOperation(this.client, input, table) - } - - /** - * Bulk decrypt models with encrypted values - * Usage: - * await eqlClient.bulkDecryptModels(encryptedModels) - * await eqlClient.bulkDecryptModels(encryptedModels).withLockContext(lockContext) - */ - bulkDecryptModels>( - input: Array, - ): BulkDecryptModelsOperation { - return new BulkDecryptModelsOperation(this.client, input) - } - - /** - * Bulk encryption - returns a thenable object. - * Usage: - * await eqlClient.bulkEncrypt(plaintexts, { column, table }) - * await eqlClient.bulkEncrypt(plaintexts, { column, table }).withLockContext(lockContext) - */ - bulkEncrypt( - plaintexts: BulkEncryptPayload, - opts: EncryptOptions, - ): BulkEncryptOperation { - return new BulkEncryptOperation(this.client, plaintexts, opts) - } - - /** - * Bulk decryption - returns a thenable object. - * Usage: - * await eqlClient.bulkDecrypt(encryptedPayloads) - * await eqlClient.bulkDecrypt(encryptedPayloads).withLockContext(lockContext) - */ - bulkDecrypt(encryptedPayloads: BulkDecryptPayload): BulkDecryptOperation { - return new BulkDecryptOperation(this.client, encryptedPayloads) - } - - /** - * Create search terms to use in a query searching encrypted data - * - * @deprecated Use `encryptQuery(terms)` instead. - * - * Migration example: - * ```typescript - * // Before (deprecated) - * const result = await client.createSearchTerms([ - * { value: 'test', column: users.email, table: users } - * ]) - * - * // After - * const result = await client.encryptQuery([ - * { value: 'test', column: users.email, table: users, queryType: 'equality' } - * ]) - * ``` - * - * Usage: - * await eqlClient.createSearchTerms(searchTerms) - * await eqlClient.createSearchTerms(searchTerms).withLockContext(lockContext) - */ - createSearchTerms(terms: SearchTerm[]): SearchTermsOperation { - return new SearchTermsOperation(this.client, terms) - } -} diff --git a/packages/protect/src/ffi/model-helpers.ts b/packages/protect/src/ffi/model-helpers.ts deleted file mode 100644 index ff6823426..000000000 --- a/packages/protect/src/ffi/model-helpers.ts +++ /dev/null @@ -1,952 +0,0 @@ -import { - type Encrypted as CipherStashEncrypted, - type DecryptBulkOptions, - decryptBulk, - encryptBulk, - type JsPlaintext, -} from '@cipherstash/protect-ffi' -import type { ProtectTable, ProtectTableColumn } from '@cipherstash/schema' -import { isEncryptedPayload } from '../helpers' -import type { GetLockContextResponse } from '../identify' -import type { Client, Decrypted, Encrypted } from '../types' -import type { AuditData } from './operations/base-operation' - -/** - * Helper function to extract encrypted fields from a model - */ -export function extractEncryptedFields>( - model: T, -): Record { - const result: Record = {} - - for (const [key, value] of Object.entries(model)) { - if (isEncryptedPayload(value)) { - result[key] = value - } - } - - return result -} - -/** - * Helper function to extract non-encrypted fields from a model - */ -export function extractOtherFields>( - model: T, -): Record { - const result: Record = {} - - for (const [key, value] of Object.entries(model)) { - if (!isEncryptedPayload(value)) { - result[key] = value - } - } - - return result -} - -/** - * Helper function to merge encrypted and non-encrypted fields into a model - */ -export function mergeFields( - otherFields: Record, - encryptedFields: Record, -): T { - return { ...otherFields, ...encryptedFields } as T -} - -/** - * Base interface for bulk operation payloads - */ -interface BulkOperationPayload { - id: string - [key: string]: unknown -} - -/** - * Interface for bulk operation key mapping - */ -interface BulkOperationKeyMap { - modelIndex: number - fieldKey: string -} - -/** - * Helper function to handle single model bulk operations with mapping - */ -async function handleSingleModelBulkOperation< - T extends BulkOperationPayload, - R, ->( - items: T[], - operation: (items: T[]) => Promise, - keyMap: Record, -): Promise> { - if (items.length === 0) { - return {} - } - - const results = await operation(items) - const mappedResults: Record = {} - - results.forEach((result, index) => { - const originalKey = keyMap[index.toString()] - mappedResults[originalKey] = result - }) - - return mappedResults -} - -/** - * Helper function to handle multiple model bulk operations with mapping - */ -async function handleMultiModelBulkOperation( - items: T[], - operation: (items: T[]) => Promise, - keyMap: Record, -): Promise> { - if (items.length === 0) { - return {} - } - - const results = await operation(items) - const mappedResults: Record = {} - - results.forEach((result, index) => { - const key = index.toString() - const { modelIndex, fieldKey } = keyMap[key] - mappedResults[`${modelIndex}-${fieldKey}`] = result - }) - - return mappedResults -} - -/** - * Helper function to prepare fields for decryption - */ -function prepareFieldsForDecryption>( - model: T, -): { - otherFields: Record - operationFields: Record - keyMap: Record - nullFields: Record -} { - const otherFields = { ...model } as Record - const operationFields: Record = {} - const nullFields: Record = {} - const keyMap: Record = {} - let index = 0 - - const processNestedFields = (obj: Record, prefix = '') => { - for (const [key, value] of Object.entries(obj)) { - const fullKey = prefix ? `${prefix}.${key}` : key - - if (value === null || value === undefined) { - nullFields[fullKey] = value - continue - } - - if (typeof value === 'object' && !isEncryptedPayload(value)) { - // Recursively process nested objects - processNestedFields(value as Record, fullKey) - } else if (isEncryptedPayload(value)) { - // This is an encrypted field - const id = index.toString() - keyMap[id] = fullKey - operationFields[fullKey] = value - index++ - - // Remove from otherFields - const parts = fullKey.split('.') - let current = otherFields - for (let i = 0; i < parts.length - 1; i++) { - current = current[parts[i]] as Record - } - delete current[parts[parts.length - 1]] - } - } - } - - processNestedFields(model) - return { otherFields, operationFields, keyMap, nullFields } -} - -/** - * Helper function to prepare fields for encryption - */ -function prepareFieldsForEncryption>( - model: T, - table: ProtectTable, -): { - otherFields: Record - operationFields: Record - keyMap: Record - nullFields: Record -} { - const otherFields = { ...model } as Record - const operationFields: Record = {} - const nullFields: Record = {} - const keyMap: Record = {} - let index = 0 - - const processNestedFields = ( - obj: Record, - prefix = '', - columnPaths: string[] = [], - ) => { - for (const [key, value] of Object.entries(obj)) { - const fullKey = prefix ? `${prefix}.${key}` : key - - if (value === null || value === undefined) { - nullFields[fullKey] = value - continue - } - - if ( - typeof value === 'object' && - !isEncryptedPayload(value) && - !columnPaths.includes(fullKey) - ) { - // Only process nested objects if they're in the schema - if (columnPaths.some((path) => path.startsWith(fullKey))) { - processNestedFields( - value as Record, - fullKey, - columnPaths, - ) - } - } else if (columnPaths.includes(fullKey)) { - // Only process fields that are explicitly defined in the schema - const id = index.toString() - keyMap[id] = fullKey - operationFields[fullKey] = value - index++ - - // Remove from otherFields - const parts = fullKey.split('.') - let current = otherFields - for (let i = 0; i < parts.length - 1; i++) { - current = current[parts[i]] as Record - } - delete current[parts[parts.length - 1]] - } - } - } - - // Get all column paths from the table schema - const columnPaths = Object.keys(table.build().columns) - processNestedFields(model, '', columnPaths) - - return { otherFields, operationFields, keyMap, nullFields } -} - -/** - * Helper function to convert a model with encrypted fields to a decrypted model - */ -export async function decryptModelFields>( - model: T, - client: Client, - auditData?: AuditData, -): Promise> { - if (!client) { - throw new Error('Client not initialized') - } - - const { otherFields, operationFields, keyMap, nullFields } = - prepareFieldsForDecryption(model) - - const bulkDecryptPayload = Object.entries(operationFields).map( - ([key, value]) => ({ - id: key, - ciphertext: value as CipherStashEncrypted, - }), - ) - - const decryptedFields = await handleSingleModelBulkOperation( - bulkDecryptPayload, - (items) => - decryptBulk(client, { - ciphertexts: items, - unverifiedContext: auditData?.metadata, - }), - keyMap, - ) - - // Helper function to set a nested value - const setNestedValue = ( - obj: Record, - path: string[], - value: unknown, - ) => { - let current = obj - for (let i = 0; i < path.length - 1; i++) { - const part = path[i] - if (!(part in current)) { - current[part] = {} - } - current = current[part] as Record - } - current[path[path.length - 1]] = value - } - - // Reconstruct the object with proper nesting - const result: Record = { ...otherFields } - - // First, reconstruct the null/undefined fields - for (const [key, value] of Object.entries(nullFields)) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - // Then, reconstruct the decrypted fields - for (const [key, value] of Object.entries(decryptedFields)) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - return result as Decrypted -} - -/** - * Helper function to convert a decrypted model to a model with encrypted fields - */ -export async function encryptModelFields>( - model: Decrypted, - table: ProtectTable, - client: Client, - auditData?: AuditData, -): Promise { - if (!client) { - throw new Error('Client not initialized') - } - - const { otherFields, operationFields, keyMap, nullFields } = - prepareFieldsForEncryption(model, table) - - const bulkEncryptPayload = Object.entries(operationFields).map( - ([key, value]) => ({ - id: key, - plaintext: value as string, - table: table.tableName, - column: key, - }), - ) - - const encryptedData = await handleSingleModelBulkOperation( - bulkEncryptPayload, - (items) => - encryptBulk(client, { - plaintexts: items, - unverifiedContext: auditData?.metadata, - }), - keyMap, - ) - - // Helper function to set a nested value - const setNestedValue = ( - obj: Record, - path: string[], - value: unknown, - ) => { - let current = obj - for (let i = 0; i < path.length - 1; i++) { - const part = path[i] - if (!(part in current)) { - current[part] = {} - } - current = current[part] as Record - } - current[path[path.length - 1]] = value - } - - // Reconstruct the object with proper nesting - const result: Record = { ...otherFields } - - // First, reconstruct the null/undefined fields - for (const [key, value] of Object.entries(nullFields)) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - // Then, reconstruct the encrypted fields - for (const [key, value] of Object.entries(encryptedData)) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - return result as T -} - -/** - * Helper function to convert a model with encrypted fields to a decrypted model with lock context - */ -export async function decryptModelFieldsWithLockContext< - T extends Record, ->( - model: T, - client: Client, - lockContext: GetLockContextResponse, - auditData?: AuditData, -): Promise> { - if (!client) { - throw new Error('Client not initialized') - } - - if (!lockContext) { - throw new Error('Lock context is not initialized') - } - - const { otherFields, operationFields, keyMap, nullFields } = - prepareFieldsForDecryption(model) - - const bulkDecryptPayload = Object.entries(operationFields).map( - ([key, value]) => ({ - id: key, - ciphertext: value as CipherStashEncrypted, - lockContext: lockContext.context, - }), - ) - - const decryptedFields = await handleSingleModelBulkOperation( - bulkDecryptPayload, - (items) => - decryptBulk(client, { - ciphertexts: items, - serviceToken: lockContext.ctsToken, - unverifiedContext: auditData?.metadata, - }), - keyMap, - ) - - // Helper function to set a nested value - const setNestedValue = ( - obj: Record, - path: string[], - value: unknown, - ) => { - let current = obj - for (let i = 0; i < path.length - 1; i++) { - const part = path[i] - if (!(part in current)) { - current[part] = {} - } - current = current[part] as Record - } - current[path[path.length - 1]] = value - } - - // Reconstruct the object with proper nesting - const result: Record = { ...otherFields } - - // First, reconstruct the null/undefined fields - for (const [key, value] of Object.entries(nullFields)) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - // Then, reconstruct the decrypted fields - for (const [key, value] of Object.entries(decryptedFields)) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - return result as Decrypted -} - -/** - * Helper function to convert a decrypted model to a model with encrypted fields with lock context - */ -export async function encryptModelFieldsWithLockContext< - T extends Record, ->( - model: Decrypted, - table: ProtectTable, - client: Client, - lockContext: GetLockContextResponse, - auditData?: AuditData, -): Promise { - if (!client) { - throw new Error('Client not initialized') - } - - if (!lockContext) { - throw new Error('Lock context is not initialized') - } - - const { otherFields, operationFields, keyMap, nullFields } = - prepareFieldsForEncryption(model, table) - - const bulkEncryptPayload = Object.entries(operationFields).map( - ([key, value]) => ({ - id: key, - plaintext: value as string, - table: table.tableName, - column: key, - lockContext: lockContext.context, - }), - ) - - const encryptedData = await handleSingleModelBulkOperation( - bulkEncryptPayload, - (items) => - encryptBulk(client, { - plaintexts: items, - serviceToken: lockContext.ctsToken, - unverifiedContext: auditData?.metadata, - }), - keyMap, - ) - - // Helper function to set a nested value - const setNestedValue = ( - obj: Record, - path: string[], - value: unknown, - ) => { - let current = obj - for (let i = 0; i < path.length - 1; i++) { - const part = path[i] - if (!(part in current)) { - current[part] = {} - } - current = current[part] as Record - } - current[path[path.length - 1]] = value - } - - // Reconstruct the object with proper nesting - const result: Record = { ...otherFields } - - // First, reconstruct the null/undefined fields - for (const [key, value] of Object.entries(nullFields)) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - // Then, reconstruct the encrypted fields - for (const [key, value] of Object.entries(encryptedData)) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - return result as T -} - -/** - * Helper function to prepare multiple models for bulk operation - */ -function prepareBulkModelsForOperation>( - models: T[], - table?: ProtectTable, -): { - otherFields: Record[] - operationFields: Record[] - keyMap: Record - nullFields: Record[] -} { - const otherFields: Record[] = [] - const operationFields: Record[] = [] - const nullFields: Record[] = [] - const keyMap: Record = {} - let index = 0 - - for (let modelIndex = 0; modelIndex < models.length; modelIndex++) { - const model = models[modelIndex] - const modelOtherFields = { ...model } as Record - const modelOperationFields: Record = {} - const modelNullFields: Record = {} - - const processNestedFields = ( - obj: Record, - prefix = '', - columnPaths: string[] = [], - ) => { - for (const [key, value] of Object.entries(obj)) { - const fullKey = prefix ? `${prefix}.${key}` : key - - if (value === null || value === undefined) { - modelNullFields[fullKey] = value - continue - } - - if ( - typeof value === 'object' && - !isEncryptedPayload(value) && - !columnPaths.includes(fullKey) - ) { - // Only process nested objects if they're in the schema - if (columnPaths.some((path) => path.startsWith(fullKey))) { - processNestedFields( - value as Record, - fullKey, - columnPaths, - ) - } - } else if (columnPaths.includes(fullKey)) { - // Only process fields that are explicitly defined in the schema - const id = index.toString() - keyMap[id] = { modelIndex, fieldKey: fullKey } - modelOperationFields[fullKey] = value - index++ - - // Remove from otherFields - const parts = fullKey.split('.') - let current = modelOtherFields - for (let i = 0; i < parts.length - 1; i++) { - current = current[parts[i]] as Record - } - delete current[parts[parts.length - 1]] - } - } - } - - if (table) { - // Get all column paths from the table schema - const columnPaths = Object.keys(table.build().columns) - processNestedFields(model, '', columnPaths) - } else { - // For decryption, process all encrypted fields - const processEncryptedFields = ( - obj: Record, - prefix = '', - columnPaths: string[] = [], - ) => { - for (const [key, value] of Object.entries(obj)) { - const fullKey = prefix ? `${prefix}.${key}` : key - - if (value === null || value === undefined) { - modelNullFields[fullKey] = value - continue - } - - if ( - typeof value === 'object' && - !isEncryptedPayload(value) && - !columnPaths.includes(fullKey) - ) { - // Recursively process nested objects - processEncryptedFields( - value as Record, - fullKey, - columnPaths, - ) - } else if (isEncryptedPayload(value)) { - // This is an encrypted field - const id = index.toString() - keyMap[id] = { modelIndex, fieldKey: fullKey } - modelOperationFields[fullKey] = value - index++ - - // Remove from otherFields - const parts = fullKey.split('.') - let current = modelOtherFields - for (let i = 0; i < parts.length - 1; i++) { - current = current[parts[i]] as Record - } - delete current[parts[parts.length - 1]] - } - } - } - processEncryptedFields(model) - } - - otherFields.push(modelOtherFields) - operationFields.push(modelOperationFields) - nullFields.push(modelNullFields) - } - - return { otherFields, operationFields, keyMap, nullFields } -} - -/** - * Helper function to convert multiple decrypted models to models with encrypted fields - */ -export async function bulkEncryptModels>( - models: Decrypted[], - table: ProtectTable, - client: Client, - auditData?: AuditData, -): Promise { - if (!client) { - throw new Error('Client not initialized') - } - - if (!models || models.length === 0) { - return [] - } - - const { otherFields, operationFields, keyMap, nullFields } = - prepareBulkModelsForOperation(models, table) - - const bulkEncryptPayload = operationFields.flatMap((fields, modelIndex) => - Object.entries(fields).map(([key, value]) => ({ - id: `${modelIndex}-${key}`, - plaintext: value as string, - table: table.tableName, - column: key, - })), - ) - - const encryptedData = await handleMultiModelBulkOperation( - bulkEncryptPayload, - (items) => - encryptBulk(client, { - plaintexts: items, - unverifiedContext: auditData?.metadata, - }), - keyMap, - ) - - // Helper function to set a nested value - const setNestedValue = ( - obj: Record, - path: string[], - value: unknown, - ) => { - let current = obj - for (let i = 0; i < path.length - 1; i++) { - const part = path[i] - if (!(part in current)) { - current[part] = {} - } - current = current[part] as Record - } - current[path[path.length - 1]] = value - } - - return models.map((_, modelIndex) => { - const result: Record = { ...otherFields[modelIndex] } - - // First, reconstruct the null/undefined fields - for (const [key, value] of Object.entries(nullFields[modelIndex])) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - // Then, reconstruct the encrypted fields - const modelData = Object.fromEntries( - Object.entries(encryptedData) - .filter(([key]) => { - const [idx] = key.split('-') - return Number.parseInt(idx) === modelIndex - }) - .map(([key, value]) => { - const [_, fieldKey] = key.split('-') - return [fieldKey, value] - }), - ) - - for (const [key, value] of Object.entries(modelData)) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - return result as T - }) -} - -/** - * Helper function to convert multiple models with encrypted fields to decrypted models - */ -export async function bulkDecryptModels>( - models: T[], - client: Client, - auditData?: AuditData, -): Promise[]> { - if (!client) { - throw new Error('Client not initialized') - } - - if (!models || models.length === 0) { - return [] - } - - const { otherFields, operationFields, keyMap, nullFields } = - prepareBulkModelsForOperation(models) - - const bulkDecryptPayload = operationFields.flatMap((fields, modelIndex) => - Object.entries(fields).map(([key, value]) => ({ - id: `${modelIndex}-${key}`, - ciphertext: value as CipherStashEncrypted, - })), - ) - - const decryptedFields = await handleMultiModelBulkOperation( - bulkDecryptPayload, - (items) => - decryptBulk(client, { - ciphertexts: items, - unverifiedContext: auditData?.metadata, - }), - keyMap, - ) - - // Helper function to set a nested value - const setNestedValue = ( - obj: Record, - path: string[], - value: unknown, - ) => { - let current = obj - for (let i = 0; i < path.length - 1; i++) { - const part = path[i] - if (!(part in current)) { - current[part] = {} - } - current = current[part] as Record - } - current[path[path.length - 1]] = value - } - - return models.map((_, modelIndex) => { - const result: Record = { ...otherFields[modelIndex] } - - // First, reconstruct the null/undefined fields - for (const [key, value] of Object.entries(nullFields[modelIndex])) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - // Then, reconstruct the decrypted fields - const modelData = Object.fromEntries( - Object.entries(decryptedFields) - .filter(([key]) => { - const [idx] = key.split('-') - return Number.parseInt(idx) === modelIndex - }) - .map(([key, value]) => { - const [_, fieldKey] = key.split('-') - return [fieldKey, value] - }), - ) - - for (const [key, value] of Object.entries(modelData)) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - return result as Decrypted - }) -} - -/** - * Helper function to convert multiple models with encrypted fields to decrypted models with lock context - */ -export async function bulkDecryptModelsWithLockContext< - T extends Record, ->( - models: T[], - client: Client, - lockContext: GetLockContextResponse, - auditData?: AuditData, -): Promise[]> { - if (!client) { - throw new Error('Client not initialized') - } - - if (!lockContext) { - throw new Error('Lock context is not initialized') - } - - const { otherFields, operationFields, keyMap, nullFields } = - prepareBulkModelsForOperation(models) - - const bulkDecryptPayload = operationFields.flatMap((fields, modelIndex) => - Object.entries(fields).map(([key, value]) => ({ - id: `${modelIndex}-${key}`, - ciphertext: value as CipherStashEncrypted, - lockContext: lockContext.context, - })), - ) - - const decryptedFields = await handleMultiModelBulkOperation( - bulkDecryptPayload, - (items) => - decryptBulk(client, { - ciphertexts: items, - serviceToken: lockContext.ctsToken, - unverifiedContext: auditData?.metadata, - }), - keyMap, - ) - - // Reconstruct models - return models.map((_, modelIndex) => ({ - ...otherFields[modelIndex], - ...nullFields[modelIndex], - ...Object.fromEntries( - Object.entries(decryptedFields) - .filter(([key]) => { - const [idx] = key.split('-') - return Number.parseInt(idx) === modelIndex - }) - .map(([key, value]) => { - const [_, fieldKey] = key.split('-') - return [fieldKey, value] - }), - ), - })) as Decrypted[] -} - -/** - * Helper function to convert multiple decrypted models to models with encrypted fields with lock context - */ -export async function bulkEncryptModelsWithLockContext< - T extends Record, ->( - models: Decrypted[], - table: ProtectTable, - client: Client, - lockContext: GetLockContextResponse, - auditData?: AuditData, -): Promise { - if (!client) { - throw new Error('Client not initialized') - } - - if (!lockContext) { - throw new Error('Lock context is not initialized') - } - - const { otherFields, operationFields, keyMap, nullFields } = - prepareBulkModelsForOperation(models, table) - - const bulkEncryptPayload = operationFields.flatMap((fields, modelIndex) => - Object.entries(fields).map(([key, value]) => ({ - id: `${modelIndex}-${key}`, - plaintext: value as string, - table: table.tableName, - column: key, - lockContext: lockContext.context, - })), - ) - - const encryptedData = await handleMultiModelBulkOperation( - bulkEncryptPayload, - (items) => - encryptBulk(client, { - plaintexts: items, - serviceToken: lockContext.ctsToken, - unverifiedContext: auditData?.metadata, - }), - keyMap, - ) - - // Reconstruct models - return models.map((_, modelIndex) => ({ - ...otherFields[modelIndex], - ...nullFields[modelIndex], - ...Object.fromEntries( - Object.entries(encryptedData) - .filter(([key]) => { - const [idx] = key.split('-') - return Number.parseInt(idx) === modelIndex - }) - .map(([key, value]) => { - const [_, fieldKey] = key.split('-') - return [fieldKey, value] - }), - ), - })) as T[] -} diff --git a/packages/protect/src/ffi/operations/base-operation.ts b/packages/protect/src/ffi/operations/base-operation.ts deleted file mode 100644 index 8e4dd36b8..000000000 --- a/packages/protect/src/ffi/operations/base-operation.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Result } from '@byteslice/result' -import type { ProtectError } from '../..' - -export type AuditConfig = { - metadata?: Record -} - -export type AuditData = { - metadata?: Record -} - -export abstract class ProtectOperation { - protected auditMetadata?: Record - - /** - * Attach audit metadata to this operation. Can be chained. - * @param config Configuration for ZeroKMS audit logging - * @param config.metadata Arbitrary JSON object for appending metadata to the audit log - */ - audit(config: AuditConfig): this { - this.auditMetadata = config.metadata - return this - } - - /** - * Get the audit data for this operation. - */ - public getAuditData(): AuditData { - return { - metadata: this.auditMetadata, - } - } - - /** - * Execute the operation and return a Result - */ - abstract execute(): Promise> - - /** - * Make the operation thenable - */ - public then, TResult2 = never>( - onfulfilled?: - | ((value: Result) => TResult1 | PromiseLike) - | null, - onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, - ): Promise { - return this.execute().then(onfulfilled, onrejected) - } -} diff --git a/packages/protect/src/ffi/operations/batch-encrypt-query.ts b/packages/protect/src/ffi/operations/batch-encrypt-query.ts deleted file mode 100644 index a8ebca064..000000000 --- a/packages/protect/src/ffi/operations/batch-encrypt-query.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import type { - Encrypted as CipherStashEncrypted, - EncryptedQuery as CipherStashEncryptedQuery, -} from '@cipherstash/protect-ffi' -import { - encryptQueryBulk as ffiEncryptQueryBulk, - type JsPlaintext, - type QueryPayload, -} from '@cipherstash/protect-ffi' -import { logger } from '../../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '../..' -import { formatEncryptedResult } from '../../helpers' -import type { Context, LockContext } from '../../identify' -import type { Client, EncryptedQueryResult, ScalarQueryTerm } from '../../types' -import { getErrorCode } from '../helpers/error-code' -import { resolveIndexType } from '../helpers/infer-index-type' -import { - assertValidNumericValue, - assertValueIndexCompatibility, -} from '../helpers/validation' -import { noClientError } from '../index' -import { ProtectOperation } from './base-operation' - -/** - * Separates null/undefined values from non-null terms in the input array. - * Returns a set of indices where values are null/undefined and an array of non-null terms with their original indices. - */ -function filterNullTerms(terms: readonly ScalarQueryTerm[]): { - nullIndices: Set - nonNullTerms: { term: ScalarQueryTerm; originalIndex: number }[] -} { - const nullIndices = new Set() - const nonNullTerms: { term: ScalarQueryTerm; originalIndex: number }[] = [] - - terms.forEach((term, index) => { - if (term.value === null || term.value === undefined) { - nullIndices.add(index) - } else { - nonNullTerms.push({ term, originalIndex: index }) - } - }) - - return { nullIndices, nonNullTerms } -} - -/** - * Validates and transforms a single term into a QueryPayload. - * Throws an error if the value is NaN or Infinity. - * Optionally includes lockContext if provided. - */ -function buildQueryPayload( - term: ScalarQueryTerm, - lockContext?: Context, -): QueryPayload { - assertValidNumericValue(term.value) - - const { indexType, queryOp } = resolveIndexType( - term.column, - term.queryType, - term.value, - ) - - // Validate value/index compatibility - assertValueIndexCompatibility(term.value, indexType, term.column.getName()) - - const payload: QueryPayload = { - plaintext: term.value as JsPlaintext, - column: term.column.getName(), - table: term.table.tableName, - indexType, - queryOp, - } - - if (lockContext != null) { - payload.lockContext = lockContext - } - - return payload -} - -/** - * Reconstructs the results array with nulls in their original positions. - * Non-null encrypted values are placed at their original indices. - * Applies formatting based on term.returnType. - */ -function assembleResults( - totalLength: number, - encryptedValues: (CipherStashEncrypted | CipherStashEncryptedQuery)[], - nonNullTerms: { term: ScalarQueryTerm; originalIndex: number }[], -): EncryptedQueryResult[] { - const results: EncryptedQueryResult[] = new Array(totalLength).fill(null) - - // Fill in encrypted values at their original positions, applying formatting - nonNullTerms.forEach(({ term, originalIndex }, i) => { - const encrypted = encryptedValues[i] - - results[originalIndex] = formatEncryptedResult(encrypted, term.returnType) - }) - - return results -} - -/** - * @internal Use {@link ProtectClient.encryptQuery} with array input instead. - */ -export class BatchEncryptQueryOperation extends ProtectOperation< - EncryptedQueryResult[] -> { - constructor( - private client: Client, - private terms: readonly ScalarQueryTerm[], - ) { - super() - } - - public withLockContext( - lockContext: LockContext, - ): BatchEncryptQueryOperationWithLockContext { - return new BatchEncryptQueryOperationWithLockContext( - this.client, - this.terms, - lockContext, - this.auditMetadata, - ) - } - - public async execute(): Promise< - Result - > { - logger.debug('Encrypting query terms', { count: this.terms.length }) - - if (this.terms.length === 0) { - return { data: [] } - } - - const { nullIndices, nonNullTerms } = filterNullTerms(this.terms) - - if (nonNullTerms.length === 0) { - return { data: this.terms.map(() => null) } - } - - return await withResult( - async () => { - if (!this.client) throw noClientError() - - const { metadata } = this.getAuditData() - - const queries: QueryPayload[] = nonNullTerms.map(({ term }) => - buildQueryPayload(term), - ) - - const encrypted = await ffiEncryptQueryBulk(this.client, { - queries, - unverifiedContext: metadata, - }) - - return assembleResults(this.terms.length, encrypted, nonNullTerms) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} - -/** - * @internal Use {@link ProtectClient.encryptQuery} with array input and `.withLockContext()` instead. - */ -export class BatchEncryptQueryOperationWithLockContext extends ProtectOperation< - EncryptedQueryResult[] -> { - constructor( - private client: Client, - private terms: readonly ScalarQueryTerm[], - private lockContext: LockContext, - auditMetadata?: Record, - ) { - super() - this.auditMetadata = auditMetadata - } - - public async execute(): Promise< - Result - > { - logger.debug('Encrypting query terms with lock context', { - count: this.terms.length, - }) - - if (this.terms.length === 0) { - return { data: [] } - } - - // Check for all-null terms BEFORE fetching lockContext to avoid unnecessary network call - const { nullIndices, nonNullTerms } = filterNullTerms(this.terms) - - if (nonNullTerms.length === 0) { - return { data: this.terms.map(() => null) } - } - - const lockContextResult = await this.lockContext.getLockContext() - if (lockContextResult.failure) { - return { failure: lockContextResult.failure } - } - - const { ctsToken, context } = lockContextResult.data - - return await withResult( - async () => { - if (!this.client) throw noClientError() - - const { metadata } = this.getAuditData() - - const queries: QueryPayload[] = nonNullTerms.map(({ term }) => - buildQueryPayload(term, context), - ) - - const encrypted = await ffiEncryptQueryBulk(this.client, { - queries, - serviceToken: ctsToken, - unverifiedContext: metadata, - }) - - return assembleResults(this.terms.length, encrypted, nonNullTerms) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} diff --git a/packages/protect/src/ffi/operations/bulk-decrypt-models.ts b/packages/protect/src/ffi/operations/bulk-decrypt-models.ts deleted file mode 100644 index dda93bff7..000000000 --- a/packages/protect/src/ffi/operations/bulk-decrypt-models.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import { logger } from '../../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '../..' -import type { LockContext } from '../../identify' -import type { Client, Decrypted } from '../../types' -import { getErrorCode } from '../helpers/error-code' -import { noClientError } from '../index' -import { - bulkDecryptModels, - bulkDecryptModelsWithLockContext, -} from '../model-helpers' -import { ProtectOperation } from './base-operation' - -export class BulkDecryptModelsOperation< - T extends Record, -> extends ProtectOperation[]> { - private client: Client - private models: T[] - - constructor(client: Client, models: T[]) { - super() - this.client = client - this.models = models - } - - public withLockContext( - lockContext: LockContext, - ): BulkDecryptModelsOperationWithLockContext { - return new BulkDecryptModelsOperationWithLockContext(this, lockContext) - } - - public async execute(): Promise[], ProtectError>> { - logger.debug('Bulk decrypting models WITHOUT a lock context') - - return await withResult( - async () => { - if (!this.client) { - throw noClientError() - } - - const auditData = this.getAuditData() - - return await bulkDecryptModels(this.models, this.client, auditData) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.DecryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } - - public getOperation(): { - client: Client - models: T[] - } { - return { - client: this.client, - models: this.models, - } - } -} - -export class BulkDecryptModelsOperationWithLockContext< - T extends Record, -> extends ProtectOperation[]> { - private operation: BulkDecryptModelsOperation - private lockContext: LockContext - - constructor( - operation: BulkDecryptModelsOperation, - lockContext: LockContext, - ) { - super() - this.operation = operation - this.lockContext = lockContext - } - - public async execute(): Promise[], ProtectError>> { - return await withResult( - async () => { - const { client, models } = this.operation.getOperation() - - logger.debug('Bulk decrypting models WITH a lock context') - - if (!client) { - throw noClientError() - } - - const context = await this.lockContext.getLockContext() - - if (context.failure) { - throw new Error(`[protect]: ${context.failure.message}`) - } - - const auditData = this.getAuditData() - - return await bulkDecryptModelsWithLockContext( - models, - client, - context.data, - auditData, - ) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.DecryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} diff --git a/packages/protect/src/ffi/operations/bulk-decrypt.ts b/packages/protect/src/ffi/operations/bulk-decrypt.ts deleted file mode 100644 index e34c85dcf..000000000 --- a/packages/protect/src/ffi/operations/bulk-decrypt.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import { - type Encrypted as CipherStashEncrypted, - type DecryptResult, - decryptBulkFallible, -} from '@cipherstash/protect-ffi' -import { logger } from '../../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '../..' -import type { Context, LockContext } from '../../identify' -import type { BulkDecryptedData, BulkDecryptPayload, Client } from '../../types' -import { getErrorCode } from '../helpers/error-code' -import { noClientError } from '../index' -import { ProtectOperation } from './base-operation' - -// Helper functions for better composability -const createDecryptPayloads = ( - encryptedPayloads: BulkDecryptPayload, - lockContext?: Context, -) => { - return encryptedPayloads - .map((item, index) => ({ ...item, originalIndex: index })) - .filter(({ data }) => data !== null) - .map(({ id, data, originalIndex }) => ({ - id, - ciphertext: data as CipherStashEncrypted, - originalIndex, - ...(lockContext && { lockContext }), - })) -} - -const createNullResult = ( - encryptedPayloads: BulkDecryptPayload, -): BulkDecryptedData => { - return encryptedPayloads.map(({ id }) => ({ - id, - data: null, - })) -} - -const mapDecryptedDataToResult = ( - encryptedPayloads: BulkDecryptPayload, - decryptedData: DecryptResult[], -): BulkDecryptedData => { - const result: BulkDecryptedData = new Array(encryptedPayloads.length) - let decryptedIndex = 0 - - for (let i = 0; i < encryptedPayloads.length; i++) { - if (encryptedPayloads[i].data === null) { - result[i] = { id: encryptedPayloads[i].id, data: null } - } else { - const decryptResult = decryptedData[decryptedIndex] - if ('error' in decryptResult) { - result[i] = { - id: encryptedPayloads[i].id, - error: decryptResult.error, - } - } else { - result[i] = { - id: encryptedPayloads[i].id, - data: decryptResult.data, - } - } - decryptedIndex++ - } - } - - return result -} - -export class BulkDecryptOperation extends ProtectOperation { - private client: Client - private encryptedPayloads: BulkDecryptPayload - - constructor(client: Client, encryptedPayloads: BulkDecryptPayload) { - super() - this.client = client - this.encryptedPayloads = encryptedPayloads - } - - public withLockContext( - lockContext: LockContext, - ): BulkDecryptOperationWithLockContext { - return new BulkDecryptOperationWithLockContext(this, lockContext) - } - - public async execute(): Promise> { - logger.debug('Bulk decrypting data WITHOUT a lock context') - return await withResult( - async () => { - if (!this.client) throw noClientError() - if (!this.encryptedPayloads || this.encryptedPayloads.length === 0) - return [] - - const nonNullPayloads = createDecryptPayloads(this.encryptedPayloads) - - if (nonNullPayloads.length === 0) { - return createNullResult(this.encryptedPayloads) - } - - const { metadata } = this.getAuditData() - - const decryptedData = await decryptBulkFallible(this.client, { - ciphertexts: nonNullPayloads, - unverifiedContext: metadata, - }) - - return mapDecryptedDataToResult(this.encryptedPayloads, decryptedData) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.DecryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } - - public getOperation(): { - client: Client - encryptedPayloads: BulkDecryptPayload - } { - return { - client: this.client, - encryptedPayloads: this.encryptedPayloads, - } - } -} - -export class BulkDecryptOperationWithLockContext extends ProtectOperation { - private operation: BulkDecryptOperation - private lockContext: LockContext - - constructor(operation: BulkDecryptOperation, lockContext: LockContext) { - super() - this.operation = operation - this.lockContext = lockContext - } - - public async execute(): Promise> { - return await withResult( - async () => { - const { client, encryptedPayloads } = this.operation.getOperation() - logger.debug('Bulk decrypting data WITH a lock context') - - if (!client) throw noClientError() - if (!encryptedPayloads || encryptedPayloads.length === 0) return [] - - const context = await this.lockContext.getLockContext() - if (context.failure) { - throw new Error(`[protect]: ${context.failure.message}`) - } - - const nonNullPayloads = createDecryptPayloads( - encryptedPayloads, - context.data.context, - ) - - if (nonNullPayloads.length === 0) { - return createNullResult(encryptedPayloads) - } - - const { metadata } = this.getAuditData() - - const decryptedData = await decryptBulkFallible(client, { - ciphertexts: nonNullPayloads, - serviceToken: context.data.ctsToken, - unverifiedContext: metadata, - }) - - return mapDecryptedDataToResult(encryptedPayloads, decryptedData) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.DecryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} diff --git a/packages/protect/src/ffi/operations/bulk-encrypt-models.ts b/packages/protect/src/ffi/operations/bulk-encrypt-models.ts deleted file mode 100644 index 2bb69446b..000000000 --- a/packages/protect/src/ffi/operations/bulk-encrypt-models.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import type { ProtectTable, ProtectTableColumn } from '@cipherstash/schema' -import { logger } from '../../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '../..' -import type { LockContext } from '../../identify' -import type { Client, Decrypted } from '../../types' -import { getErrorCode } from '../helpers/error-code' -import { noClientError } from '../index' -import { - bulkEncryptModels, - bulkEncryptModelsWithLockContext, -} from '../model-helpers' -import { ProtectOperation } from './base-operation' - -export class BulkEncryptModelsOperation< - T extends Record, -> extends ProtectOperation { - private client: Client - private models: Decrypted[] - private table: ProtectTable - - constructor( - client: Client, - models: Decrypted[], - table: ProtectTable, - ) { - super() - this.client = client - this.models = models - this.table = table - } - - public withLockContext( - lockContext: LockContext, - ): BulkEncryptModelsOperationWithLockContext { - return new BulkEncryptModelsOperationWithLockContext(this, lockContext) - } - - public async execute(): Promise> { - logger.debug('Bulk encrypting models WITHOUT a lock context', { - table: this.table.tableName, - }) - - return await withResult( - async () => { - if (!this.client) { - throw noClientError() - } - - const auditData = this.getAuditData() - - return await bulkEncryptModels( - this.models, - this.table, - this.client, - auditData, - ) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } - - public getOperation(): { - client: Client - models: Decrypted[] - table: ProtectTable - } { - return { - client: this.client, - models: this.models, - table: this.table, - } - } -} - -export class BulkEncryptModelsOperationWithLockContext< - T extends Record, -> extends ProtectOperation { - private operation: BulkEncryptModelsOperation - private lockContext: LockContext - - constructor( - operation: BulkEncryptModelsOperation, - lockContext: LockContext, - ) { - super() - this.operation = operation - this.lockContext = lockContext - } - - public async execute(): Promise> { - return await withResult( - async () => { - const { client, models, table } = this.operation.getOperation() - - logger.debug('Bulk encrypting models WITH a lock context', { - table: table.tableName, - }) - - if (!client) { - throw noClientError() - } - - const context = await this.lockContext.getLockContext() - - if (context.failure) { - throw new Error(`[protect]: ${context.failure.message}`) - } - - const auditData = this.getAuditData() - - return await bulkEncryptModelsWithLockContext( - models, - table, - client, - context.data, - auditData, - ) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} diff --git a/packages/protect/src/ffi/operations/bulk-encrypt.ts b/packages/protect/src/ffi/operations/bulk-encrypt.ts deleted file mode 100644 index 8b9dd78db..000000000 --- a/packages/protect/src/ffi/operations/bulk-encrypt.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import { encryptBulk, type JsPlaintext } from '@cipherstash/protect-ffi' -import type { - ProtectColumn, - ProtectTable, - ProtectTableColumn, - ProtectValue, -} from '@cipherstash/schema' -import { logger } from '../../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '../..' -import type { Context, LockContext } from '../../identify' -import type { - BulkEncryptedData, - BulkEncryptPayload, - Client, - Encrypted, - EncryptOptions, -} from '../../types' -import { getErrorCode } from '../helpers/error-code' -import { noClientError } from '../index' -import { ProtectOperation } from './base-operation' - -// Helper functions for better composability -const createEncryptPayloads = ( - plaintexts: BulkEncryptPayload, - column: ProtectColumn | ProtectValue, - table: ProtectTable, - lockContext?: Context, -) => { - return plaintexts - .map((item, index) => ({ ...item, originalIndex: index })) - .filter(({ plaintext }) => plaintext !== null) - .map(({ id, plaintext, originalIndex }) => ({ - id, - plaintext: plaintext as JsPlaintext, - column: column.getName(), - table: table.tableName, - originalIndex, - ...(lockContext && { lockContext }), - })) -} - -const createNullResult = ( - plaintexts: BulkEncryptPayload, -): BulkEncryptedData => { - return plaintexts.map(({ id }) => ({ id, data: null })) -} - -const mapEncryptedDataToResult = ( - plaintexts: BulkEncryptPayload, - encryptedData: Encrypted[], -): BulkEncryptedData => { - const result: BulkEncryptedData = new Array(plaintexts.length) - let encryptedIndex = 0 - - for (let i = 0; i < plaintexts.length; i++) { - if (plaintexts[i].plaintext === null) { - result[i] = { id: plaintexts[i].id, data: null } - } else { - result[i] = { - id: plaintexts[i].id, - data: encryptedData[encryptedIndex], - } - encryptedIndex++ - } - } - - return result -} - -export class BulkEncryptOperation extends ProtectOperation { - private client: Client - private plaintexts: BulkEncryptPayload - private column: ProtectColumn | ProtectValue - private table: ProtectTable - - constructor( - client: Client, - plaintexts: BulkEncryptPayload, - opts: EncryptOptions, - ) { - super() - this.client = client - this.plaintexts = plaintexts - this.column = opts.column - this.table = opts.table - } - - public withLockContext( - lockContext: LockContext, - ): BulkEncryptOperationWithLockContext { - return new BulkEncryptOperationWithLockContext(this, lockContext) - } - - public async execute(): Promise> { - logger.debug('Bulk encrypting data WITHOUT a lock context', { - column: this.column.getName(), - table: this.table.tableName, - }) - - return await withResult( - async () => { - if (!this.client) { - throw noClientError() - } - if (!this.plaintexts || this.plaintexts.length === 0) { - return [] - } - - const nonNullPayloads = createEncryptPayloads( - this.plaintexts, - this.column, - this.table, - ) - - if (nonNullPayloads.length === 0) { - return createNullResult(this.plaintexts) - } - - const { metadata } = this.getAuditData() - - const encryptedData = await encryptBulk(this.client, { - plaintexts: nonNullPayloads, - unverifiedContext: metadata, - }) - - return mapEncryptedDataToResult(this.plaintexts, encryptedData) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } - - public getOperation(): { - client: Client - plaintexts: BulkEncryptPayload - column: ProtectColumn | ProtectValue - table: ProtectTable - } { - return { - client: this.client, - plaintexts: this.plaintexts, - column: this.column, - table: this.table, - } - } -} - -export class BulkEncryptOperationWithLockContext extends ProtectOperation { - private operation: BulkEncryptOperation - private lockContext: LockContext - - constructor(operation: BulkEncryptOperation, lockContext: LockContext) { - super() - this.operation = operation - this.lockContext = lockContext - } - - public async execute(): Promise> { - return await withResult( - async () => { - const { client, plaintexts, column, table } = - this.operation.getOperation() - - logger.debug('Bulk encrypting data WITH a lock context', { - column: column.getName(), - table: table.tableName, - }) - - if (!client) { - throw noClientError() - } - if (!plaintexts || plaintexts.length === 0) { - return [] - } - - const context = await this.lockContext.getLockContext() - if (context.failure) { - throw new Error(`[protect]: ${context.failure.message}`) - } - - const nonNullPayloads = createEncryptPayloads( - plaintexts, - column, - table, - context.data.context, - ) - - if (nonNullPayloads.length === 0) { - return createNullResult(plaintexts) - } - - const { metadata } = this.getAuditData() - - const encryptedData = await encryptBulk(client, { - plaintexts: nonNullPayloads, - serviceToken: context.data.ctsToken, - unverifiedContext: metadata, - }) - - return mapEncryptedDataToResult(plaintexts, encryptedData) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} diff --git a/packages/protect/src/ffi/operations/decrypt-model.ts b/packages/protect/src/ffi/operations/decrypt-model.ts deleted file mode 100644 index 3bdf3c34b..000000000 --- a/packages/protect/src/ffi/operations/decrypt-model.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import { logger } from '../../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '../..' -import type { LockContext } from '../../identify' -import type { Client, Decrypted } from '../../types' -import { getErrorCode } from '../helpers/error-code' -import { noClientError } from '../index' -import { - decryptModelFields, - decryptModelFieldsWithLockContext, -} from '../model-helpers' -import { ProtectOperation } from './base-operation' - -export class DecryptModelOperation< - T extends Record, -> extends ProtectOperation> { - private client: Client - private model: T - - constructor(client: Client, model: T) { - super() - this.client = client - this.model = model - } - - public withLockContext( - lockContext: LockContext, - ): DecryptModelOperationWithLockContext { - return new DecryptModelOperationWithLockContext(this, lockContext) - } - - public async execute(): Promise, ProtectError>> { - logger.debug('Decrypting model WITHOUT a lock context') - - return await withResult( - async () => { - if (!this.client) { - throw noClientError() - } - - const auditData = this.getAuditData() - - return await decryptModelFields(this.model, this.client, auditData) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.DecryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } - - public getOperation(): { - client: Client - model: T - } { - return { - client: this.client, - model: this.model, - } - } -} - -export class DecryptModelOperationWithLockContext< - T extends Record, -> extends ProtectOperation> { - private operation: DecryptModelOperation - private lockContext: LockContext - - constructor(operation: DecryptModelOperation, lockContext: LockContext) { - super() - this.operation = operation - this.lockContext = lockContext - } - - public async execute(): Promise, ProtectError>> { - return await withResult( - async () => { - const { client, model } = this.operation.getOperation() - - logger.debug('Decrypting model WITH a lock context') - - if (!client) { - throw noClientError() - } - - const context = await this.lockContext.getLockContext() - - if (context.failure) { - throw new Error(`[protect]: ${context.failure.message}`) - } - - const auditData = this.getAuditData() - - return await decryptModelFieldsWithLockContext( - model, - client, - context.data, - auditData, - ) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.DecryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} diff --git a/packages/protect/src/ffi/operations/decrypt.ts b/packages/protect/src/ffi/operations/decrypt.ts deleted file mode 100644 index 98591f8ed..000000000 --- a/packages/protect/src/ffi/operations/decrypt.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import { - decrypt as ffiDecrypt, - type JsPlaintext, -} from '@cipherstash/protect-ffi' -import { logger } from '../../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '../..' -import type { LockContext } from '../../identify' -import type { Client, Encrypted } from '../../types' -import { getErrorCode } from '../helpers/error-code' -import { noClientError } from '../index' -import { ProtectOperation } from './base-operation' - -/** - * Decrypts an encrypted payload using the provided client. - * This is the type returned by the {@link ProtectClient.decrypt | decrypt} method of the {@link ProtectClient}. - */ -export class DecryptOperation extends ProtectOperation { - private client: Client - private encryptedData: Encrypted - - constructor(client: Client, encryptedData: Encrypted) { - super() - this.client = client - this.encryptedData = encryptedData - } - - public withLockContext( - lockContext: LockContext, - ): DecryptOperationWithLockContext { - return new DecryptOperationWithLockContext(this, lockContext) - } - - public async execute(): Promise> { - return await withResult( - async () => { - if (!this.client) { - throw noClientError() - } - - if (this.encryptedData === null) { - return null - } - - const { metadata } = this.getAuditData() - - logger.debug('Decrypting data WITHOUT a lock context', { - metadata, - }) - - return await ffiDecrypt(this.client, { - ciphertext: this.encryptedData, - unverifiedContext: metadata, - }) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.DecryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } - - public getOperation(): { - client: Client - encryptedData: Encrypted - auditData?: Record - } { - return { - client: this.client, - encryptedData: this.encryptedData, - auditData: this.getAuditData(), - } - } -} - -export class DecryptOperationWithLockContext extends ProtectOperation { - private operation: DecryptOperation - private lockContext: LockContext - - constructor(operation: DecryptOperation, lockContext: LockContext) { - super() - this.operation = operation - this.lockContext = lockContext - const auditData = operation.getAuditData() - if (auditData) { - this.audit(auditData) - } - } - - public async execute(): Promise> { - return await withResult( - async () => { - const { client, encryptedData } = this.operation.getOperation() - - if (!client) { - throw noClientError() - } - - if (encryptedData === null) { - return null - } - - const { metadata } = this.getAuditData() - - logger.debug('Decrypting data WITH a lock context', { - metadata, - }) - - const context = await this.lockContext.getLockContext() - - if (context.failure) { - throw new Error(`[protect]: ${context.failure.message}`) - } - - return await ffiDecrypt(client, { - ciphertext: encryptedData, - unverifiedContext: metadata, - lockContext: context.data.context, - serviceToken: context.data.ctsToken, - }) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.DecryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} diff --git a/packages/protect/src/ffi/operations/deprecated/search-terms.ts b/packages/protect/src/ffi/operations/deprecated/search-terms.ts deleted file mode 100644 index f0b2c2603..000000000 --- a/packages/protect/src/ffi/operations/deprecated/search-terms.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import { encryptQueryBulk, type QueryPayload } from '@cipherstash/protect-ffi' -import { logger } from '../../../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '../../..' -import type { LockContext } from '../../../identify' -import type { Client, EncryptedSearchTerm, SearchTerm } from '../../../types' -import { noClientError } from '../..' -import { getErrorCode } from '../../helpers/error-code' -import { inferIndexType } from '../../helpers/infer-index-type' -import { ProtectOperation } from '../base-operation' - -/** - * @deprecated Use `BatchEncryptQueryOperation` instead. - * This class is maintained for backward compatibility only. - */ -export class SearchTermsOperation extends ProtectOperation< - EncryptedSearchTerm[] -> { - constructor( - private client: Client, - private terms: SearchTerm[], - ) { - super() - } - - public withLockContext( - lockContext: LockContext, - ): SearchTermsOperationWithLockContext { - return new SearchTermsOperationWithLockContext(this, lockContext) - } - - public async execute(): Promise> { - logger.debug('Creating search terms (deprecated API)', { - count: this.terms.length, - }) - - return await withResult( - async () => { - if (!this.client) throw noClientError() - - const { metadata } = this.getAuditData() - - const queries: QueryPayload[] = this.terms.map((term) => ({ - plaintext: term.value, - column: term.column.getName(), - table: term.table.tableName, - indexType: inferIndexType(term.column), - })) - - const encryptedTerms = await encryptQueryBulk(this.client, { - queries, - unverifiedContext: metadata, - }) - - return this.terms.map((term, index) => { - if (term.returnType === 'composite-literal') { - return `(${JSON.stringify(JSON.stringify(encryptedTerms[index]))})` - } - if (term.returnType === 'escaped-composite-literal') { - return `${JSON.stringify(`(${JSON.stringify(JSON.stringify(encryptedTerms[index]))})`)}` - } - return encryptedTerms[index] - }) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} - -export class SearchTermsOperationWithLockContext extends ProtectOperation< - EncryptedSearchTerm[] -> { - constructor( - private operation: SearchTermsOperation, - private lockContext: LockContext, - ) { - super() - this.auditMetadata = (operation as any).auditMetadata - } - - public async execute(): Promise> { - const lockContextResult = await this.lockContext.getLockContext() - if (lockContextResult.failure) { - return { failure: lockContextResult.failure } - } - - const { ctsToken, context } = lockContextResult.data - const op = this.operation as any - - return await withResult( - async () => { - if (!op.client) throw noClientError() - - const { metadata } = this.getAuditData() - - const queries: QueryPayload[] = op.terms.map((term: SearchTerm) => ({ - plaintext: term.value, - column: term.column.getName(), - table: term.table.tableName, - indexType: inferIndexType(term.column), - lockContext: context, - })) - - const encryptedTerms = await encryptQueryBulk(op.client, { - queries, - serviceToken: ctsToken, - unverifiedContext: metadata, - }) - - return op.terms.map((term: SearchTerm, index: number) => { - if (term.returnType === 'composite-literal') { - return `(${JSON.stringify(JSON.stringify(encryptedTerms[index]))})` - } - if (term.returnType === 'escaped-composite-literal') { - return `${JSON.stringify(`(${JSON.stringify(JSON.stringify(encryptedTerms[index]))})`)}` - } - return encryptedTerms[index] - }) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} diff --git a/packages/protect/src/ffi/operations/encrypt-model.ts b/packages/protect/src/ffi/operations/encrypt-model.ts deleted file mode 100644 index 54f1bb59e..000000000 --- a/packages/protect/src/ffi/operations/encrypt-model.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import type { ProtectTable, ProtectTableColumn } from '@cipherstash/schema' -import { logger } from '../../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '../..' -import type { LockContext } from '../../identify' -import type { Client, Decrypted } from '../../types' -import { getErrorCode } from '../helpers/error-code' -import { noClientError } from '../index' -import { - encryptModelFields, - encryptModelFieldsWithLockContext, -} from '../model-helpers' -import { ProtectOperation } from './base-operation' - -export class EncryptModelOperation< - T extends Record, -> extends ProtectOperation { - private client: Client - private model: Decrypted - private table: ProtectTable - - constructor( - client: Client, - model: Decrypted, - table: ProtectTable, - ) { - super() - this.client = client - this.model = model - this.table = table - } - - public withLockContext( - lockContext: LockContext, - ): EncryptModelOperationWithLockContext { - return new EncryptModelOperationWithLockContext(this, lockContext) - } - - public async execute(): Promise> { - logger.debug('Encrypting model WITHOUT a lock context', { - table: this.table.tableName, - }) - - return await withResult( - async () => { - if (!this.client) { - throw noClientError() - } - - const auditData = this.getAuditData() - - return await encryptModelFields( - this.model, - this.table, - this.client, - auditData, - ) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } - - public getOperation(): { - client: Client - model: Decrypted - table: ProtectTable - } { - return { - client: this.client, - model: this.model, - table: this.table, - } - } -} - -export class EncryptModelOperationWithLockContext< - T extends Record, -> extends ProtectOperation { - private operation: EncryptModelOperation - private lockContext: LockContext - - constructor(operation: EncryptModelOperation, lockContext: LockContext) { - super() - this.operation = operation - this.lockContext = lockContext - } - - public async execute(): Promise> { - return await withResult( - async () => { - const { client, model, table } = this.operation.getOperation() - - logger.debug('Encrypting model WITH a lock context', { - table: table.tableName, - }) - - if (!client) { - throw noClientError() - } - - const context = await this.lockContext.getLockContext() - - if (context.failure) { - throw new Error(`[protect]: ${context.failure.message}`) - } - - const auditData = this.getAuditData() - - return await encryptModelFieldsWithLockContext( - model, - table, - client, - context.data, - auditData, - ) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} diff --git a/packages/protect/src/ffi/operations/encrypt-query.ts b/packages/protect/src/ffi/operations/encrypt-query.ts deleted file mode 100644 index bb633ccea..000000000 --- a/packages/protect/src/ffi/operations/encrypt-query.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import { - encryptQuery as ffiEncryptQuery, - type JsPlaintext, -} from '@cipherstash/protect-ffi' -import { logger } from '../../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '../..' -import { formatEncryptedResult } from '../../helpers' -import type { LockContext } from '../../identify' -import type { - Client, - EncryptedQueryResult, - EncryptQueryOptions, -} from '../../types' -import { getErrorCode } from '../helpers/error-code' -import { resolveIndexType } from '../helpers/infer-index-type' -import { - assertValueIndexCompatibility, - validateNumericValue, -} from '../helpers/validation' -import { noClientError } from '../index' -import { ProtectOperation } from './base-operation' - -/** - * @internal Use {@link ProtectClient.encryptQuery} instead. - */ -export class EncryptQueryOperation extends ProtectOperation { - constructor( - private client: Client, - private plaintext: JsPlaintext | null, - private opts: EncryptQueryOptions, - ) { - super() - } - - public withLockContext( - lockContext: LockContext, - ): EncryptQueryOperationWithLockContext { - return new EncryptQueryOperationWithLockContext( - this.client, - this.plaintext, - this.opts, - lockContext, - this.auditMetadata, - ) - } - - public async execute(): Promise> { - logger.debug('Encrypting query', { - column: this.opts.column.getName(), - table: this.opts.table.tableName, - queryType: this.opts.queryType, - }) - - if (this.plaintext === null || this.plaintext === undefined) { - return { data: null } - } - - const validationError = validateNumericValue(this.plaintext) - if (validationError?.failure) { - return { failure: validationError.failure } - } - - return await withResult( - async () => { - if (!this.client) throw noClientError() - - const { metadata } = this.getAuditData() - - const { indexType, queryOp } = resolveIndexType( - this.opts.column, - this.opts.queryType, - this.plaintext, - ) - - // Validate value/index compatibility - assertValueIndexCompatibility( - this.plaintext, - indexType, - this.opts.column.getName(), - ) - - const encrypted = await ffiEncryptQuery(this.client, { - plaintext: this.plaintext as JsPlaintext, - column: this.opts.column.getName(), - table: this.opts.table.tableName, - indexType, - queryOp, - unverifiedContext: metadata, - }) - - return formatEncryptedResult(encrypted, this.opts.returnType) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } - - public getOperation() { - return { client: this.client, plaintext: this.plaintext, ...this.opts } - } -} - -/** - * @internal Use {@link ProtectClient.encryptQuery} with `.withLockContext()` instead. - */ -export class EncryptQueryOperationWithLockContext extends ProtectOperation { - constructor( - private client: Client, - private plaintext: JsPlaintext | null, - private opts: EncryptQueryOptions, - private lockContext: LockContext, - auditMetadata?: Record, - ) { - super() - this.auditMetadata = auditMetadata - } - - public async execute(): Promise> { - if (this.plaintext === null || this.plaintext === undefined) { - return { data: null } - } - - const validationError = validateNumericValue(this.plaintext) - if (validationError?.failure) { - return { failure: validationError.failure } - } - - const lockContextResult = await this.lockContext.getLockContext() - if (lockContextResult.failure) { - return { failure: lockContextResult.failure } - } - - const { ctsToken, context } = lockContextResult.data - - return await withResult( - async () => { - if (!this.client) throw noClientError() - - const { metadata } = this.getAuditData() - - const { indexType, queryOp } = resolveIndexType( - this.opts.column, - this.opts.queryType, - this.plaintext, - ) - - // Validate value/index compatibility - assertValueIndexCompatibility( - this.plaintext, - indexType, - this.opts.column.getName(), - ) - - const encrypted = await ffiEncryptQuery(this.client, { - plaintext: this.plaintext as JsPlaintext, - column: this.opts.column.getName(), - table: this.opts.table.tableName, - indexType, - queryOp, - lockContext: context, - serviceToken: ctsToken, - unverifiedContext: metadata, - }) - - return formatEncryptedResult(encrypted, this.opts.returnType) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} diff --git a/packages/protect/src/ffi/operations/encrypt.ts b/packages/protect/src/ffi/operations/encrypt.ts deleted file mode 100644 index d91afa757..000000000 --- a/packages/protect/src/ffi/operations/encrypt.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import { - encrypt as ffiEncrypt, - type JsPlaintext, -} from '@cipherstash/protect-ffi' -import type { - ProtectColumn, - ProtectTable, - ProtectTableColumn, - ProtectValue, -} from '@cipherstash/schema' -import { logger } from '../../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '../..' -import type { LockContext } from '../../identify' -import type { Client, Encrypted, EncryptOptions } from '../../types' -import { getErrorCode } from '../helpers/error-code' -import { noClientError } from '../index' -import { ProtectOperation } from './base-operation' - -export class EncryptOperation extends ProtectOperation { - private client: Client - private plaintext: JsPlaintext | null - private column: ProtectColumn | ProtectValue - private table: ProtectTable - - constructor( - client: Client, - plaintext: JsPlaintext | null, - opts: EncryptOptions, - ) { - super() - this.client = client - this.plaintext = plaintext - this.column = opts.column - this.table = opts.table - } - - public withLockContext( - lockContext: LockContext, - ): EncryptOperationWithLockContext { - return new EncryptOperationWithLockContext(this, lockContext) - } - - public async execute(): Promise> { - logger.debug('Encrypting data WITHOUT a lock context', { - column: this.column.getName(), - table: this.table.tableName, - }) - - return await withResult( - async () => { - if (!this.client) { - throw noClientError() - } - - if (this.plaintext === null) { - return null - } - - if ( - typeof this.plaintext === 'number' && - Number.isNaN(this.plaintext) - ) { - throw new Error('[protect]: Cannot encrypt NaN value') - } - - if ( - typeof this.plaintext === 'number' && - !Number.isFinite(this.plaintext) - ) { - throw new Error('[protect]: Cannot encrypt Infinity value') - } - - const { metadata } = this.getAuditData() - - return await ffiEncrypt(this.client, { - plaintext: this.plaintext, - column: this.column.getName(), - table: this.table.tableName, - unverifiedContext: metadata, - }) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } - - public getOperation(): { - client: Client - plaintext: JsPlaintext | null - column: ProtectColumn | ProtectValue - table: ProtectTable - } { - return { - client: this.client, - plaintext: this.plaintext, - column: this.column, - table: this.table, - } - } -} - -export class EncryptOperationWithLockContext extends ProtectOperation { - private operation: EncryptOperation - private lockContext: LockContext - - constructor(operation: EncryptOperation, lockContext: LockContext) { - super() - this.operation = operation - this.lockContext = lockContext - } - - public async execute(): Promise> { - return await withResult( - async () => { - const { client, plaintext, column, table } = - this.operation.getOperation() - - logger.debug('Encrypting data WITH a lock context', { - column: column, - table: table, - }) - - if (!client) { - throw noClientError() - } - - if (plaintext === null) { - return null - } - - const { metadata } = this.getAuditData() - const context = await this.lockContext.getLockContext() - - if (context.failure) { - throw new Error(`[protect]: ${context.failure.message}`) - } - - return await ffiEncrypt(client, { - plaintext, - column: column.getName(), - table: table.tableName, - lockContext: context.data.context, - serviceToken: context.data.ctsToken, - unverifiedContext: metadata, - }) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} diff --git a/packages/protect/src/helpers/index.ts b/packages/protect/src/helpers/index.ts deleted file mode 100644 index aae2909b5..000000000 --- a/packages/protect/src/helpers/index.ts +++ /dev/null @@ -1,207 +0,0 @@ -import type { - Encrypted as CipherStashEncrypted, - EncryptedQuery as CipherStashEncryptedQuery, - EncryptedScalarQuery, - KeysetIdentifier as KeysetIdentifierFfi, -} from '@cipherstash/protect-ffi' -import type { - Encrypted, - EncryptedQueryResult, - KeysetIdentifier, -} from '../types' - -/** - * The shape `encryptQuery` / `encryptQueryBulk` can return: a full storage - * payload (`Encrypted`, returned for `ste_vec_term` containment queries) or a - * query-only payload with no ciphertext (`EncryptedQuery`, returned for - * scalar `unique`/`match`/`ore` lookups and `ste_vec_selector` path queries). - * - * TODO: duplicated in `@cipherstash/stack` — see - * `packages/stack/src/encryption/helpers/index.ts`. Both copies should be - * removed once `@cipherstash/protect-ffi` exports a named alias for the - * `encryptQuery` return type (https://github.com/cipherstash/stack/pull/473). - */ -type EncryptedQueryTerm = CipherStashEncrypted | CipherStashEncryptedQuery - -export type EncryptedPgComposite = { - data: Encrypted -} - -/** - * Helper function to transform an encrypted payload into a PostgreSQL composite type. - * Use this when inserting data via Supabase or similar clients. - */ -export function encryptedToPgComposite(obj: Encrypted): EncryptedPgComposite { - return { - data: obj, - } -} - -/** - * Helper function to transform an encrypted payload into a PostgreSQL composite literal string. - * Use this when querying with `.eq()` or similar equality operations in Supabase. - * - * @deprecated Use `encryptQuery()` with `returnType: 'composite-literal'` instead. - * @example - * ```typescript - * // Before (deprecated): - * const [encrypted] = await protectClient.encryptQuery([ - * { value: searchValue, column, table, queryType: 'equality' } - * ]) - * const literal = encryptedToCompositeLiteral(encrypted) - * await supabase.from('table').select().eq('column', literal) - * - * // After (recommended): - * const [searchTerm] = await protectClient.encryptQuery([ - * { value: searchValue, column, table, queryType: 'equality', returnType: 'composite-literal' } - * ]) - * await supabase.from('table').select().eq('column', searchTerm) - * ``` - */ -export function encryptedToCompositeLiteral(obj: EncryptedQueryTerm): string { - if (obj === null) { - throw new Error('encryptedToCompositeLiteral: obj cannot be null') - } - return `(${JSON.stringify(JSON.stringify(obj))})` -} - -/** - * Helper function to transform an encrypted payload into an escaped PostgreSQL composite literal string. - * Use this when you need the composite literal format to be escaped as a string value. - * - * @deprecated Use `encryptQuery()` with `returnType: 'escaped-composite-literal'` instead. - * See also: `encryptedToCompositeLiteral` for parallel deprecation guidance. - * @example - * ```typescript - * // Before (deprecated): - * const [encrypted] = await protectClient.encryptQuery([ - * { value: searchValue, column, table, queryType: 'equality' } - * ]) - * const escapedLiteral = encryptedToEscapedCompositeLiteral(encrypted) - * - * // After (recommended): - * const [searchTerm] = await protectClient.encryptQuery([ - * { value: searchValue, column, table, queryType: 'equality', returnType: 'escaped-composite-literal' } - * ]) - * ``` - */ -export function encryptedToEscapedCompositeLiteral( - obj: EncryptedQueryTerm, -): string { - if (obj === null) { - throw new Error('encryptedToEscapedCompositeLiteral: obj cannot be null') - } - return JSON.stringify(encryptedToCompositeLiteral(obj)) -} - -export function formatEncryptedResult( - encrypted: EncryptedQueryTerm, - returnType?: string, -): EncryptedQueryResult { - if (returnType === 'composite-literal') { - return encryptedToCompositeLiteral(encrypted) - } - if (returnType === 'escaped-composite-literal') { - return encryptedToEscapedCompositeLiteral(encrypted) - } - return encrypted -} - -/** - * Helper function to transform a model's encrypted fields into PostgreSQL composite types - */ -export function modelToEncryptedPgComposites>( - model: T, -): T { - const result: Record = {} - - for (const [key, value] of Object.entries(model)) { - if (isEncryptedPayload(value)) { - result[key] = encryptedToPgComposite(value) - } else { - result[key] = value - } - } - - return result as T -} - -/** - * Helper function to transform multiple models' encrypted fields into PostgreSQL composite types - */ -export function bulkModelsToEncryptedPgComposites< - T extends Record, ->(models: T[]): T[] { - return models.map((model) => modelToEncryptedPgComposites(model)) -} - -export function toFfiKeysetIdentifier( - keyset: KeysetIdentifier | undefined, -): KeysetIdentifierFfi | undefined { - if (!keyset) return undefined - - if ('name' in keyset) { - return { Name: keyset.name } - } - - return { Uuid: keyset.id } -} - -/** - * Helper function to check if a value is an encrypted payload - */ -export function isEncryptedPayload(value: unknown): value is Encrypted { - if (value === null) return false - - // TODO: this can definitely be improved - if (typeof value === 'object') { - const obj = value as Encrypted - return ( - obj !== null && 'v' in obj && ('c' in obj || 'sv' in obj) && 'i' in obj - ) - } - - return false -} - -/** - * Type guard narrowing a value to {@link EncryptedScalarQuery} — the scalar - * query term (`unique` / `match` / `ore` lookup) returned by `encryptQuery` / - * `encryptQueryBulk`. Unlike a storage payload it carries no ciphertext (`c`); - * it carries exactly one lookup term: `hm`, `bf`, or `ob`. - * - * Use this to discriminate a scalar query term from a storage payload - * (`EncryptedScalar`/`EncryptedSteVec`) or a `ste_vec_selector` query. - */ -export function isEncryptedScalarQuery( - value: unknown, -): value is EncryptedScalarQuery { - if (value === null || typeof value !== 'object') return false - - const obj = value as Record - - // `k: 'ct'` is the scalar discriminant; a query term never carries the - // ciphertext (`c`) that every storage payload has. - if (obj.k !== 'ct' || 'c' in obj) return false - if ( - typeof obj.v !== 'number' || - typeof obj.i !== 'object' || - obj.i === null - ) { - return false - } - - // Exactly one lookup term: `hm` (unique), `bf` (match), or `ob` (ore). - const lookupTerms = [ - typeof obj.hm === 'string', - Array.isArray(obj.bf), - Array.isArray(obj.ob), - ].filter(Boolean) - return lookupTerms.length === 1 -} - -export { - buildNestedObject, - parseJsonbPath, - toJsonPath, -} from './jsonb' diff --git a/packages/protect/src/helpers/jsonb.ts b/packages/protect/src/helpers/jsonb.ts deleted file mode 100644 index 018ea99c4..000000000 --- a/packages/protect/src/helpers/jsonb.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * JSONB path utilities for converting between path formats. - * - * These utilities support dot-notation and basic JSONPath-style array indices (e.g., "[0]"). - * Only limited validation is performed (forbidden prototype keys); callers should still - * ensure segments are valid property names. - */ - -/** - * Convert a dot-notation path to JSONPath selector format. - * - * @example - * toJsonPath("user.email") // "$.user.email" - * toJsonPath("$.user.email") // "$.user.email" (unchanged) - * toJsonPath(".user.email") // "$.user.email" - * toJsonPath("name") // "$.name" - */ -export function toJsonPath(path: string): string { - if (!path || path === '$') return '$' - if (path.startsWith('$[')) return path - if (path.startsWith('$.')) return path - if (path.startsWith('$')) return `$.${path.slice(1)}` - if (path.startsWith('.')) return `$${path}` - if (path.startsWith('[')) return `$${path}` - return `$.${path}` -} - -/** - * Parse a JSONB path string into segments. - * Handles both dot notation and JSONPath format. - * - * Returns an empty array for empty, null, or undefined input (defensive for JS consumers). - * - * @example - * parseJsonbPath("user.email") // ["user", "email"] - * parseJsonbPath("$.user.email") // ["user", "email"] - * parseJsonbPath("name") // ["name"] - * parseJsonbPath("$.name") // ["name"] - */ -export function parseJsonbPath(path: string): string[] { - if (!path || typeof path !== 'string') return [] - - // Remove leading $. or $ prefix - const normalized = path.replace(/^\$\.?/, '') - - if (!normalized) return [] - - return normalized.split('.').filter(Boolean) -} - -/** - * Build a nested object from a dot-notation path and value. - * - * @example - * buildNestedObject("user.role", "admin") - * // Returns: { user: { role: "admin" } } - * - * buildNestedObject("name", "alice") - * // Returns: { name: "alice" } - * - * buildNestedObject("a.b.c", 123) - * // Returns: { a: { b: { c: 123 } } } - */ -const FORBIDDEN_KEYS = ['__proto__', 'prototype', 'constructor'] - -function validateSegment(segment: string): void { - if (FORBIDDEN_KEYS.includes(segment)) { - throw new Error(`Path contains forbidden segment: ${segment}`) - } -} - -export function buildNestedObject( - path: string, - value: unknown, -): Record { - if (!path) { - throw new Error('Path cannot be empty') - } - - const segments = parseJsonbPath(path) - if (segments.length === 0) { - throw new Error('Path must contain at least one segment') - } - - const result: Record = Object.create(null) - let current = result - - for (let i = 0; i < segments.length - 1; i++) { - const key = segments[i] - validateSegment(key) - current[key] = Object.create(null) - current = current[key] as Record - } - - const leafKey = segments[segments.length - 1] - validateSegment(leafKey) - current[leafKey] = value - return result -} diff --git a/packages/protect/src/identify/index.ts b/packages/protect/src/identify/index.ts deleted file mode 100644 index a1ff22be4..000000000 --- a/packages/protect/src/identify/index.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import { loadWorkSpaceId } from '../../../utils/config' -import { logger } from '../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '..' - -export type CtsRegions = 'ap-southeast-2' - -export type IdentifyOptions = { - fetchFromCts?: boolean -} - -export type CtsToken = { - accessToken: string - expiry: number -} - -export type Context = { - identityClaim: string[] -} - -export type LockContextOptions = { - context?: Context - ctsToken?: CtsToken -} - -export type GetLockContextResponse = { - ctsToken: CtsToken - context: Context -} - -export class LockContext { - private ctsToken: CtsToken | undefined - private workspaceId: string - private context: Context - - constructor({ - context = { identityClaim: ['sub'] }, - ctsToken, - }: LockContextOptions = {}) { - const workspaceId = loadWorkSpaceId() - - if (!workspaceId) { - throw new Error( - 'You have not defined a workspace ID in your config file, or the CS_WORKSPACE_ID environment variable.', - ) - } - - if (ctsToken) { - this.ctsToken = ctsToken - } - - this.workspaceId = workspaceId - this.context = context - logger.debug('Successfully initialized the EQL lock context.') - } - - async identify(jwtToken: string): Promise> { - const workspaceId = this.workspaceId - - const ctsEndpoint = - process.env.CS_CTS_ENDPOINT || - 'https://ap-southeast-2.aws.auth.viturhosted.net' - - const ctsFetchResult = await withResult( - () => - fetch(`${ctsEndpoint}/api/authorize`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - workspaceId, - oidcToken: jwtToken, - }), - }), - (error) => ({ - type: ProtectErrorTypes.CtsTokenError, - message: error.message, - }), - ) - - if (ctsFetchResult.failure) { - return ctsFetchResult - } - - const identifiedLockContext = await withResult( - async () => { - const ctsToken = (await ctsFetchResult.data.json()) as CtsToken - - if (!ctsToken.accessToken) { - throw new Error( - 'The response from the CipherStash API did not contain an access token. Please contact support.', - ) - } - - this.ctsToken = ctsToken - return this - }, - (error) => ({ - type: ProtectErrorTypes.CtsTokenError, - message: error.message, - }), - ) - - return identifiedLockContext - } - - getLockContext(): Promise> { - return withResult( - () => { - if (!this.ctsToken?.accessToken && !this.ctsToken?.expiry) { - throw new Error( - 'The CTS token is not set. Please call identify() with a users JWT token, or pass an existing CTS token to the LockContext constructor before calling getLockContext().', - ) - } - - return { - context: this.context, - ctsToken: this.ctsToken, - } - }, - (error) => ({ - type: ProtectErrorTypes.CtsTokenError, - message: error.message, - }), - ) - } -} diff --git a/packages/protect/src/index.ts b/packages/protect/src/index.ts deleted file mode 100644 index c9505f07d..000000000 --- a/packages/protect/src/index.ts +++ /dev/null @@ -1,180 +0,0 @@ -import type { ProtectTable, ProtectTableColumn } from '@cipherstash/schema' -import { buildEncryptConfig } from '@cipherstash/schema' -import { ProtectClient } from './ffi' -import type { KeysetIdentifier } from './types' - -// Re-export FFI error types for programmatic error handling -export { - ProtectError as FfiProtectError, - type ProtectErrorCode, -} from '@cipherstash/protect-ffi' - -export const ProtectErrorTypes = { - ClientInitError: 'ClientInitError', - EncryptionError: 'EncryptionError', - DecryptionError: 'DecryptionError', - LockContextError: 'LockContextError', - CtsTokenError: 'CtsTokenError', -} - -export interface ProtectError { - type: (typeof ProtectErrorTypes)[keyof typeof ProtectErrorTypes] - message: string - code?: import('@cipherstash/protect-ffi').ProtectErrorCode -} - -type AtLeastOneCsTable = [T, ...T[]] - -export type ProtectClientConfig = { - schemas: AtLeastOneCsTable> - - /** - * The CipherStash workspace CRN (Cloud Resource Name). - * Format: `crn:.aws:`. - * Can also be set via the `CS_WORKSPACE_CRN` environment variable. - */ - workspaceCrn?: string - - /** - * The API access key used for authenticating with the CipherStash API. - * Can also be set via the `CS_CLIENT_ACCESS_KEY` environment variable. - * Obtain this from the CipherStash dashboard after creating a workspace. - */ - accessKey?: string - - /** - * The client identifier used to authenticate with CipherStash services. - * Can also be set via the `CS_CLIENT_ID` environment variable. - * Generated during workspace onboarding in the CipherStash dashboard. - */ - clientId?: string - - /** - * The client key material used in combination with ZeroKMS for encryption operations. - * Can also be set via the `CS_CLIENT_KEY` environment variable. - * Generated during workspace onboarding in the CipherStash dashboard. - */ - clientKey?: string - - /** - * An optional keyset identifier for multi-tenant encryption. - * Each keyset provides cryptographic isolation, giving each tenant its own keyspace. - * Specify by name (`{ name: "tenant-a" }`) or UUID (`{ id: "..." }`). - * Keysets are created and managed in the CipherStash dashboard. - */ - keyset?: KeysetIdentifier -} - -function isValidUuid(uuid: string): boolean { - const uuidRegex = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i - return uuidRegex.test(uuid) -} - -/* Initialize a Protect client with the provided configuration. - - @param config - The configuration object for initializing the Protect client. - - @see {@link ProtectClientConfig} for details on the configuration options. - - @returns A Promise that resolves to an instance of ProtectClient. - - @throws Will throw an error if no schemas are provided or if the keyset ID is not a valid UUID. -*/ -export const protect = async ( - config: ProtectClientConfig, -): Promise => { - const { schemas } = config - - if (!schemas.length) { - throw new Error( - '[protect]: At least one csTable must be provided to initialize the protect client', - ) - } - - if ( - config.keyset && - 'id' in config.keyset && - !isValidUuid(config.keyset.id) - ) { - throw new Error( - '[protect]: Invalid UUID provided for keyset id. Must be a valid UUID.', - ) - } - - const clientConfig = { - workspaceCrn: config.workspaceCrn, - accessKey: config.accessKey, - clientId: config.clientId, - clientKey: config.clientKey, - keyset: config.keyset, - } - - const client = new ProtectClient() - const encryptConfig = buildEncryptConfig(...schemas) - - const result = await client.init({ - encryptConfig, - ...clientConfig, - }) - - if (result.failure) { - throw new Error(`[protect]: ${result.failure.message}`) - } - - return result.data -} - -export type { Result } from '@byteslice/result' -export type { - ProtectColumn, - ProtectTable, - ProtectTableColumn, - ProtectValue, -} from '@cipherstash/schema' -export { csColumn, csTable, csValue } from '@cipherstash/schema' -export type { ProtectClient } from './ffi' -// Helpers -export { - inferIndexType, - validateIndexType, -} from './ffi/helpers/infer-index-type' -export type { ProtectOperation } from './ffi/operations/base-operation' -export { - BatchEncryptQueryOperation, - BatchEncryptQueryOperationWithLockContext, -} from './ffi/operations/batch-encrypt-query' -export type { BulkDecryptOperation } from './ffi/operations/bulk-decrypt' -export type { BulkDecryptModelsOperation } from './ffi/operations/bulk-decrypt-models' -export type { BulkEncryptOperation } from './ffi/operations/bulk-encrypt' -export type { BulkEncryptModelsOperation } from './ffi/operations/bulk-encrypt-models' -export type { DecryptOperation } from './ffi/operations/decrypt' -export type { DecryptModelOperation } from './ffi/operations/decrypt-model' -export type { EncryptOperation } from './ffi/operations/encrypt' -export type { EncryptModelOperation } from './ffi/operations/encrypt-model' -// Operations -export { - EncryptQueryOperation, - EncryptQueryOperationWithLockContext, -} from './ffi/operations/encrypt-query' -export * from './helpers' -// LockContext related type exports -export type { - Context, - CtsRegions, - CtsToken, - GetLockContextResponse, - IdentifyOptions, - LockContextOptions, -} from './identify' -// LockContext class export (value export for instantiation) -export { LockContext } from './identify' -// Types -export type { - EncryptQueryOptions, - FfiIndexTypeName, - QueryTypeName, - ScalarQueryTerm, -} from './types' -export * from './types' -export { queryTypes, queryTypeToFfi } from './types' diff --git a/packages/protect/src/stash/index.ts b/packages/protect/src/stash/index.ts deleted file mode 100644 index 73338dc83..000000000 --- a/packages/protect/src/stash/index.ts +++ /dev/null @@ -1,459 +0,0 @@ -import type { Result } from '@byteslice/result' -import { csColumn, csTable } from '@cipherstash/schema' -import { encryptedToPgComposite, type ProtectClient, protect } from '../index' -import type { Encrypted } from '../types' - -export type SecretName = string -export type SecretValue = string - -/** - * Configuration options for initializing the Stash client - */ -export interface StashConfig { - workspaceCRN: string - clientId: string - clientKey: string - environment: string - apiKey: string - accessKey?: string -} - -/** - * Secret metadata returned from the API - */ -export interface SecretMetadata { - id?: string - name: string - environment: string - createdAt?: string - updatedAt?: string -} - -/** - * API response for listing secrets - */ -export interface ListSecretsResponse { - environment: string - secrets: SecretMetadata[] -} - -/** - * API response for getting a secret - */ -export interface GetSecretResponse { - name: string - environment: string - encryptedValue: { - data: Encrypted - } - createdAt?: string - updatedAt?: string -} - -export interface DecryptedSecretResponse { - name: string - environment: string - value: string - createdAt?: string - updatedAt?: string -} - -/** - * The Stash client provides a high-level API for managing encrypted secrets - * stored in CipherStash. Secrets are encrypted locally before being sent to - * the API, ensuring end-to-end encryption. - */ -export class Stash { - private protectClient: ProtectClient | null = null - private config: StashConfig - private readonly apiBaseUrl = - process.env.STASH_API_URL || 'https://getstash.sh/api/secrets' - private readonly secretsSchema = csTable('secrets', { - value: csColumn('value'), - }) - - /** - * Extracts the workspace ID from a CRN string. - * CRN format: crn:region.aws:ID - * - * @param crn The CRN string to extract from - * @returns The workspace ID portion of the CRN - */ - private extractWorkspaceIdFromCrn(crn: string): string { - const match = crn.match(/crn:[^:]+:([^:]+)$/) - if (!match) { - throw new Error('Invalid CRN format') - } - return match[1] - } - - constructor(config: StashConfig) { - this.config = config - } - - /** - * Initialize the Stash client and underlying Protect client - */ - private async ensureInitialized(): Promise { - if (this.protectClient) { - return - } - - this.protectClient = await protect({ - schemas: [this.secretsSchema], - workspaceCrn: this.config.workspaceCRN, - clientId: this.config.clientId, - clientKey: this.config.clientKey, - accessKey: this.config.apiKey, - keyset: { - name: this.config.environment, - }, - }) - } - - /** - * Get the authorization header for API requests - */ - private getAuthHeader(): string { - return `Bearer ${this.config.apiKey}` - } - - /** - * Make an API request with error handling - */ - private async apiRequest( - method: string, - path: string, - body?: unknown, - ): Promise> { - try { - const url = `${this.apiBaseUrl}${path}` - const headers: Record = { - 'Content-Type': 'application/json', - Authorization: this.getAuthHeader(), - } - - const response = await fetch(url, { - method, - headers, - body: body ? JSON.stringify(body) : undefined, - }) - - if (!response.ok) { - const errorText = await response.text() - let errorMessage = `API request failed with status ${response.status}` - try { - const errorJson = JSON.parse(errorText) - errorMessage = errorJson.message || errorMessage - } catch { - errorMessage = errorText || errorMessage - } - - return { - failure: { - type: 'ApiError', - message: errorMessage, - }, - } - } - - const data = await response.json() - return { data } - } catch (error) { - return { - failure: { - type: 'NetworkError', - message: - error instanceof Error - ? error.message - : 'Unknown network error occurred', - }, - } - } - } - - /** - * Store an encrypted secret in the vault. - * The value is encrypted locally before being sent to the API. - * - * @param name - The name of the secret - * @param value - The plaintext value to encrypt and store - * @returns A Result indicating success or failure - * - * @example - * ```typescript - * const stash = new Stash({ ... }) - * const result = await stash.set('DATABASE_URL', 'postgres://user:pass@localhost:5432/mydb') - * if (result.failure) { - * console.error('Failed to set secret:', result.failure.message) - * } - * ``` - */ - async set( - name: SecretName, - value: SecretValue, - ): Promise> { - await this.ensureInitialized() - - if (!this.protectClient) { - return { - failure: { - type: 'ClientError', - message: 'Failed to initialize Protect client', - }, - } - } - - // Encrypt the value locally - const encryptResult = await this.protectClient.encrypt(value, { - column: this.secretsSchema.value, - table: this.secretsSchema, - }) - - if (encryptResult.failure) { - return { - failure: { - type: 'EncryptionError', - message: encryptResult.failure.message, - }, - } - } - - // Extract workspaceId from CRN - const workspaceId = this.extractWorkspaceIdFromCrn(this.config.workspaceCRN) - - // Send encrypted value to API - return await this.apiRequest('POST', '/set', { - workspaceId, - environment: this.config.environment, - name, - encryptedValue: encryptedToPgComposite(encryptResult.data), - }) - } - - /** - * Retrieve and decrypt a secret from the vault. - * The secret is decrypted locally after retrieval. - * - * @param name - The name of the secret to retrieve - * @returns A Result containing the decrypted value or an error - * - * @example - * ```typescript - * const stash = new Stash({ ... }) - * const result = await stash.get('DATABASE_URL') - * if (result.failure) { - * console.error('Failed to get secret:', result.failure.message) - * } else { - * console.log('Secret value:', result.data) - * } - * ``` - */ - async get( - name: SecretName, - ): Promise> { - await this.ensureInitialized() - - if (!this.protectClient) { - return { - failure: { - type: 'ClientError', - message: 'Failed to initialize Protect client', - }, - } - } - - // Extract workspaceId from CRN - const workspaceId = this.extractWorkspaceIdFromCrn(this.config.workspaceCRN) - - // Fetch encrypted value from API - const apiResult = await this.apiRequest('POST', '/get', { - workspaceId, - environment: this.config.environment, - name, - }) - - if (apiResult.failure) { - return apiResult - } - - // Decrypt the value locally - const decryptResult = await this.protectClient.decrypt( - apiResult.data.encryptedValue.data, - ) - - if (decryptResult.failure) { - return { - failure: { - type: 'DecryptionError', - message: decryptResult.failure.message, - }, - } - } - - if (typeof decryptResult.data !== 'string') { - return { - failure: { - type: 'DecryptionError', - message: 'Decrypted value is not a string', - }, - } - } - - return { data: decryptResult.data } - } - - /** - * Retrieve and decrypt many secrets from the vault. - * The secrets are decrypted locally after retrieval. - * This method only triggers a single network request to the ZeroKMS. - * - * @param names - The names of the secrets to retrieve - * @returns A Result containing an object mapping secret names to their decrypted values - * - * @example - * ```typescript - * const stash = new Stash({ ... }) - * const result = await stash.getMany(['DATABASE_URL', 'API_KEY']) - * if (result.failure) { - * console.error('Failed to get secrets:', result.failure.message) - * } else { - * const dbUrl = result.data.DATABASE_URL // Access by name - * const apiKey = result.data.API_KEY - * } - * ``` - */ - async getMany( - names: SecretName[], - ): Promise< - Result, { type: string; message: string }> - > { - await this.ensureInitialized() - - if (!this.protectClient) { - return { - failure: { - type: 'ClientError', - message: 'Failed to initialize Protect client', - }, - } - } - - // Extract workspaceId from CRN - const workspaceId = this.extractWorkspaceIdFromCrn(this.config.workspaceCRN) - - // Fetch encrypted value from API - const apiResult = await this.apiRequest( - 'POST', - '/get-many', - { - workspaceId, - environment: this.config.environment, - names, - }, - ) - - if (apiResult.failure) { - return apiResult - } - - const dataToDecrypt = apiResult.data.map((item) => ({ - name: item.name, - value: item.encryptedValue.data, - })) - - const decryptResult = - await this.protectClient.bulkDecryptModels(dataToDecrypt) - - if (decryptResult.failure) { - return { - failure: { - type: 'DecryptionError', - message: decryptResult.failure.message, - }, - } - } - - console.log('Decrypt result:', JSON.stringify(decryptResult.data, null, 2)) - - // Transform array of decrypted secrets into an object keyed by secret name - const decryptedSecrets = - decryptResult.data as unknown as DecryptedSecretResponse[] - const secretsMap: Record = {} - - for (const secret of decryptedSecrets) { - if (secret.name && secret.value) { - secretsMap[secret.name] = secret.value - } - } - - return { data: secretsMap } - } - - /** - * List all secrets in the environment. - * Only names and metadata are returned; values remain encrypted. - * - * @returns A Result containing the list of secrets or an error - * - * @example - * ```typescript - * const stash = new Stash({ ... }) - * const result = await stash.list() - * if (result.failure) { - * console.error('Failed to list secrets:', result.failure.message) - * } else { - * console.log('Secrets:', result.data) - * } - * ``` - */ - async list(): Promise< - Result - > { - // Extract workspaceId from CRN - const workspaceId = this.extractWorkspaceIdFromCrn(this.config.workspaceCRN) - - const apiResult = await this.apiRequest( - 'POST', - '/list', - { - workspaceId, - environment: this.config.environment, - }, - ) - - if (apiResult.failure) { - return apiResult - } - - return { data: apiResult.data.secrets } - } - - /** - * Delete a secret from the vault. - * - * @param name - The name of the secret to delete - * @returns A Result indicating success or failure - * - * @example - * ```typescript - * const stash = new Stash({ ... }) - * const result = await stash.delete('DATABASE_URL') - * if (result.failure) { - * console.error('Failed to delete secret:', result.failure.message) - * } - * ``` - */ - async delete( - name: SecretName, - ): Promise> { - // Extract workspaceId from CRN - const workspaceId = this.extractWorkspaceIdFromCrn(this.config.workspaceCRN) - - return await this.apiRequest('POST', '/delete', { - workspaceId, - environment: this.config.environment, - name, - }) - } -} diff --git a/packages/protect/src/types.ts b/packages/protect/src/types.ts deleted file mode 100644 index de8e061db..000000000 --- a/packages/protect/src/types.ts +++ /dev/null @@ -1,241 +0,0 @@ -import type { - Encrypted as CipherStashEncrypted, - EncryptedQuery as CipherStashEncryptedQuery, - JsPlaintext, - newClient, - QueryOpName, -} from '@cipherstash/protect-ffi' -import type { - ProtectColumn, - ProtectTable, - ProtectTableColumn, - ProtectValue, -} from '@cipherstash/schema' - -/** - * Type to represent the client object - */ -export type Client = Awaited> | undefined - -/** - * Type to represent an encrypted payload stored in the database. Always carries - * a ciphertext — scalar payloads at the root (`c`), SteVec payloads at - * `sv[0].c`. For search-term payloads returned by `encryptQuery`, see - * {@link EncryptedQuery}. - */ -export type Encrypted = CipherStashEncrypted | null - -/** - * Type to represent an encrypted query term (search needle) returned by - * `encryptQuery` / `encryptQueryBulk` for scalar (`unique` / `match` / `ore`) - * lookups and `ste_vec_selector` JSON path queries. Carries no ciphertext — it - * is matched against stored values, never decrypted. JSON containment queries - * (`ste_vec_term`) return a storage-shaped {@link Encrypted} payload instead. - */ -export type EncryptedQuery = CipherStashEncryptedQuery | null - -/** - * Represents an encrypted payload in the database - * @deprecated Use `Encrypted` instead - */ -export type EncryptedPayload = Encrypted | null - -/** - * Represents an encrypted data object in the database - * @deprecated Use `Encrypted` instead - */ -export type EncryptedData = Encrypted | null - -/** - * Represents a value that will be encrypted and used in a search - */ -export type SearchTerm = { - value: JsPlaintext - column: ProtectColumn - table: ProtectTable - returnType?: 'eql' | 'composite-literal' | 'escaped-composite-literal' -} - -export type KeysetIdentifier = - | { - name: string - } - | { - id: string - } - -/** - * The return type of the search term based on the return type specified in the `SearchTerm` type. - * - `eql` → an `Encrypted` storage payload (for `ste_vec_term` containment) or an - * {@link EncryptedQuery} term (for scalar lookups and `ste_vec_selector` queries). - * - `composite-literal` → `string` where the value is a composite literal. - * - `escaped-composite-literal` → `string` where the value is an escaped composite literal. - */ -export type EncryptedSearchTerm = Encrypted | EncryptedQuery | string - -/** - * Result type for encryptQuery batch operations. - * Can be an `Encrypted` storage payload (e.g. for `ste_vec_term`), an - * {@link EncryptedQuery} term (for scalar lookups and `ste_vec_selector`), - * a `string` (for composite-literal formats), or `null` (for null inputs). - */ -export type EncryptedQueryResult = Encrypted | EncryptedQuery | string | null - -/** - * Represents a payload to be encrypted using the `encrypt` function - */ -export type EncryptPayload = JsPlaintext | null - -/** - * Represents the options for encrypting a payload using the `encrypt` function - */ -export type EncryptOptions = { - column: ProtectColumn | ProtectValue - table: ProtectTable -} - -/** - * Type to identify encrypted fields in a model - */ -export type EncryptedFields = { - [K in keyof T as T[K] extends Encrypted ? K : never]: T[K] -} - -/** - * Type to identify non-encrypted fields in a model - */ -export type OtherFields = { - [K in keyof T as T[K] extends Encrypted ? never : K]: T[K] -} - -/** - * Type to represent decrypted fields in a model - */ -export type DecryptedFields = { - [K in keyof T as T[K] extends Encrypted ? K : never]: string -} - -/** - * Represents a model with plaintext (decrypted) values instead of the EQL/JSONB types - */ -export type Decrypted = OtherFields & DecryptedFields - -/** - * Types for bulk encryption and decryption operations. - */ -export type BulkEncryptPayload = Array<{ - id?: string - plaintext: JsPlaintext | null -}> - -export type BulkEncryptedData = Array<{ id?: string; data: Encrypted }> -export type BulkDecryptPayload = Array<{ id?: string; data: Encrypted }> -export type BulkDecryptedData = Array> - -type DecryptionSuccess = { - error?: never - data: T - id?: string -} - -type DecryptionError = { - error: T - id?: string - data?: never -} - -export type DecryptionResult = DecryptionSuccess | DecryptionError - -/** - * User-facing query type names for encrypting query values. - * - * - `'equality'`: For exact match queries. {@link https://cipherstash.com/docs/platform/searchable-encryption/supported-queries/exact | Exact Queries} - * - `'freeTextSearch'`: For text search queries. {@link https://cipherstash.com/docs/platform/searchable-encryption/supported-queries/match | Match Queries} - * - `'orderAndRange'`: For comparison and range queries. {@link https://cipherstash.com/docs/platform/searchable-encryption/supported-queries/range | Range Queries} - * - `'steVecSelector'`: For JSONPath selector queries (e.g., '$.user.email') - * - `'steVecTerm'`: For containment queries (e.g., { role: 'admin' }) - * - `'searchableJson'`: Auto-infers selector or term based on plaintext type (recommended) - * - String values → ste_vec_selector (JSONPath queries) - * - Object/Array/Number/Boolean → ste_vec_term (containment queries) - * - * Note: For columns with an ste_vec index, `'searchableJson'` behaves identically to omitting - * `queryType` entirely - both auto-infer the query operation from the plaintext type. Using - * `'searchableJson'` explicitly is useful for code clarity and self-documenting intent. - */ -export type QueryTypeName = - | 'orderAndRange' - | 'freeTextSearch' - | 'equality' - | 'steVecSelector' - | 'steVecTerm' - | 'searchableJson' - -/** - * Internal FFI index type names. - * @internal - */ -export type FfiIndexTypeName = 'ore' | 'match' | 'unique' | 'ste_vec' - -/** - * Query type constants for use with encryptQuery(). - */ -export const queryTypes = { - orderAndRange: 'orderAndRange', - freeTextSearch: 'freeTextSearch', - equality: 'equality', - steVecSelector: 'steVecSelector', - steVecTerm: 'steVecTerm', - searchableJson: 'searchableJson', -} as const satisfies Record - -/** - * Maps user-friendly query type names to FFI index type names. - * @internal - */ -export const queryTypeToFfi: Record = { - orderAndRange: 'ore', - freeTextSearch: 'match', - equality: 'unique', - steVecSelector: 'ste_vec', - steVecTerm: 'ste_vec', - searchableJson: 'ste_vec', -} - -/** - * Maps query type names to FFI query operation names. - * Returns undefined for query types that don't need a specific queryOp. - * @internal - */ -export const queryTypeToQueryOp: Partial> = { - steVecSelector: 'ste_vec_selector', - steVecTerm: 'ste_vec_term', -} - -/** - * Base type for query term options shared between single and bulk operations. - * @internal - */ -export type QueryTermBase = { - column: ProtectColumn - table: ProtectTable - queryType?: QueryTypeName // Optional - auto-infers if omitted - /** - * The format for the returned encrypted value: - * - `'eql'` (default) - Returns raw Encrypted object - * - `'composite-literal'` - Returns PostgreSQL composite literal format `("json")` - * - `'escaped-composite-literal'` - Returns escaped format `"(\"json\")"` - */ - returnType?: 'eql' | 'composite-literal' | 'escaped-composite-literal' -} - -/** - * Options for encrypting a single query term. - */ -export type EncryptQueryOptions = QueryTermBase - -/** - * Individual query term for bulk operations. - */ -export type ScalarQueryTerm = QueryTermBase & { - value: JsPlaintext | null -} diff --git a/packages/protect/tsconfig.json b/packages/protect/tsconfig.json deleted file mode 100644 index 639824185..000000000 --- a/packages/protect/tsconfig.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "compilerOptions": { - // Enable latest features - "lib": ["ES2022", "DOM"], - "target": "ES2022", - "module": "ESNext", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - "esModuleInterop": true, - - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - } -} diff --git a/packages/protect/tsup.config.ts b/packages/protect/tsup.config.ts deleted file mode 100644 index 8fdee46c6..000000000 --- a/packages/protect/tsup.config.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { defineConfig } from 'tsup' - -export default defineConfig([ - { - entry: [ - 'src/index.ts', - 'src/client.ts', - 'src/identify/index.ts', - 'src/stash/index.ts', - ], - format: ['cjs', 'esm'], - sourcemap: true, - dts: true, - target: 'es2022', - tsconfig: './tsconfig.json', - }, - { - entry: ['src/bin/stash.ts'], - outDir: 'dist/bin', - format: ['esm'], - target: 'es2022', - banner: { - js: '#!/usr/bin/env node', - }, - dts: false, - sourcemap: true, - external: ['dotenv'], - noExternal: [], - }, -]) diff --git a/packages/schema/.npmignore b/packages/schema/.npmignore deleted file mode 100644 index 3490e24db..000000000 --- a/packages/schema/.npmignore +++ /dev/null @@ -1,5 +0,0 @@ -.env -.turbo -node_modules -cipherstash.secret.toml -cipherstash.toml \ No newline at end of file diff --git a/packages/schema/CHANGELOG.md b/packages/schema/CHANGELOG.md deleted file mode 100644 index 678414865..000000000 --- a/packages/schema/CHANGELOG.md +++ /dev/null @@ -1,104 +0,0 @@ -# @cipherstash/schema - -## 3.0.2-rc.0 - -### Patch Changes - -- 229ce59: `searchableJson()` now pins the SteVec encoding mode to `standard` explicitly. - protect-ffi 0.29 flipped the library default to `compat` (the EQL v3 - encoding); pinning keeps the v2 wire format byte-stable so existing encrypted - JSON columns stay queryable and comparable. - -## 3.0.1 - -### Patch Changes - -- aa9c4b1: Documentation: refresh package READMEs after the protectjs → stack repository rename. Fixed repository and license links, replaced dead in-repo docs links with cipherstash.com/docs URLs, rewrote the incorrect @cipherstash/nextjs README, and added guidance pointing new projects to @cipherstash/stack. - -## 3.0.0 - -### Major Changes - -- f743fcc: Upgrade `@cipherstash/protect-ffi` to `0.23.0` and the bundled CipherStash EQL extension to `eql-2.3.1`. - - Breaking upstream changes adopted in this release: - - - **Encrypt-config schema version**: `buildEncryptConfig` now emits `{ v: 1, ... }` (was `{ v: 2, ... }`). protect-ffi `0.22.0` started validating this field and rejects any value other than `1` with the new `UNSUPPORTED_CONFIG_VERSION` error code. - - **Storage and query payloads are now distinct types** (protect-ffi `0.23.0`): the previously-conflated `Encrypted` type splits into `Encrypted` (storage-only, `c` required) and a new `EncryptedQuery` (search terms — scalar `unique`/`match`/`ore` lookups and `ste_vec_selector` JSON path queries; no `c`). JSON containment queries (`ste_vec_term`) still return a storage-shaped `Encrypted` payload. `encryptQuery` / `encryptQueryBulk` now return `Encrypted | EncryptedQuery`, and the stack's `EncryptedSearchTerm` / `EncryptedQueryResult` unions widen to match. `decrypt` rejects query payloads at the type level. The DynamoDB `SearchTermsOperation` narrows via `'hm' in term` rather than `term.hm`. - - **SteVec encoding default flipped**: protect-ffi's default `mode` for `ste_vec` indexes changed from `compat` to `standard`. The two encodings are not cross-compatible. Existing JSON-searchable data that was indexed under `compat` will need to be re-encrypted to be queryable. The stack adopts the new `standard` default — there is no longer a way to pin `compat` from the SDK. - - **EQL extension bumped to `eql-2.3.1`**: the new SteVec `standard` encoding requires matching support in the database EQL extension. The CLI's bundled SQL (`packages/cli/src/sql/*.sql`) and the `@cipherstash/prisma-next` install bundle (`migrations/20260601T0000_install_eql_bundle/ops.json` + `eql-install.generated.ts`) are updated to `eql-2.3.1`. Databases installed with an older EQL extension must be reinstalled (`stash db install`) before containment / contained-by queries against SteVec columns will work. `eql-2.3.1` ships the `_encrypted_check_c` fix for SteVec storage payloads ([cipherstash/encrypt-query-language#232](https://github.com/cipherstash/encrypt-query-language/issues/232)). - - **New error codes**: `ProtectErrorCode` (re-exported from `@cipherstash/protect-ffi`) gains `MATCH_REQUIRES_TEXT` and `UNSUPPORTED_CONFIG_VERSION`. Exhaustive switches over `ProtectErrorCode` will need additional cases. - - **`match` index validation**: protect-ffi now rejects `match` indexes on columns whose `cast_as` is not text-family (`'text'` / `'string'`) with `MATCH_REQUIRES_TEXT`. The stack's `freeTextSearch()` builder is unaffected because it only targets string-typed columns. - - **`Encrypted` ciphertext shape**: protect-ffi's `Encrypted` type is now a discriminated union keyed on `k` (`'ct'` for scalars, `'sv'` for SteVec). SteVec storage payloads now place the root document ciphertext at `sv[0].c`. The stack's `isEncryptedPayload` runtime check continues to work because storage payloads still carry `c` (scalar) or `sv` (SteVec). The DynamoDB helpers (`toEncryptedDynamoItem`, `SearchTermsOperation`) now narrow on `k` before reading variant-only fields. - - **Config-validation error message wording**: error messages for config-validation failures now come from upstream `ConfigError`. `ProtectError.code` values are preserved; consumers that string-match on `err.message` for config-validation errors must update. - -## 2.2.0 - -### Minor Changes - -- b0e56b8: Upgrade protect-ffi to 0.21.0 and enable array_index_mode for searchable JSON - - - Upgrade `@cipherstash/protect-ffi` to 0.21.0 across all packages - - Enable `array_index_mode: 'all'` on STE vec indexes so JSON array operations - (jsonb_array_elements, jsonb_array_length, array containment) work correctly - - Delegate credential resolution entirely to protect-ffi's `withEnvCredentials` - - Download latest EQL at build/runtime instead of bundling hardcoded SQL files - -## 2.1.0 - -### Minor Changes - -- e769740: Add encrypted JSONB query support with `searchableJson()` (recommended). - - - New `searchableJson()` schema method enables encrypted JSONB path and containment queries - - Automatic query operation inference: string values become JSONPath selector queries, objects/arrays become containment queries - - Also supports explicit `queryType: 'steVecSelector'` and `queryType: 'steVecTerm'` for advanced use cases - - JSONB path utilities (`toJsonPath`, `buildNestedObject`, `parseJsonbPath`) for building encrypted JSON column queries - -## 2.0.2 - -### Patch Changes - -- 532ac3a: Corrected types documentation in README to match Typedoc. - `int` -> `number` - `text` -> `string` - -## 2.0.1 - -### Patch Changes - -- ff4421f: Expanded typedoc documentation - -## 2.0.0 - -### Major Changes - -- 9005484: Include EQL 2.1.8 in package distribution - -## 1.1.0 - -### Minor Changes - -- d8ed4d4: Exported all types for packages looking for deeper integrations with Protect.js. - -## 1.0.0 - -### Major Changes - -- 788dbfc: Added JSON and INT data type support and update FFI to v0.17.1 with x86_64 musl environment platform support. - - - Update @cipherstash/protect-ffi from 0.16.0 to 0.17.1 with support for x86_64 musl platforms. - - Add searchableJson() method to schema for JSON field indexing (the search operations still don't work but this interface exists) - - Refactor type system: EncryptedPayload → Encrypted, add JsPlaintext - - Add comprehensive test suites for JSON, integer, and basic encryption - - Update encryption format to use 'k' property for searchable JSON - - Remove deprecated search terms tests for JSON fields - - Simplify schema data types to text, int, json only - - Update model helpers to handle new encryption format - - Fix type safety issues in bulk operations and model encryption - -## 0.1.0 - -### Minor Changes - -- d0b02ea: Released initial package for CipherStash Encrypt schemas. diff --git a/packages/schema/README.md b/packages/schema/README.md deleted file mode 100644 index f9cc98288..000000000 --- a/packages/schema/README.md +++ /dev/null @@ -1,307 +0,0 @@ -# @cipherstash/schema - -A TypeScript schema builder for CipherStash encryption that enables you to define encryption schemas with searchable encryption capabilities. - -## Overview - -`@cipherstash/schema` is a standalone package that provides the schema building functionality used by `@cipherstash/protect`. While not required for typical usage, this package is available if you need to build encryption configuration schemas directly or want to understand the underlying schema structure. - -> [!TIP] -> For new projects we recommend [`@cipherstash/stack`](https://www.npmjs.com/package/@cipherstash/stack), which exposes this functionality as `encryptedTable` / `encryptedColumn` from `@cipherstash/stack/schema`. The `csTable` / `csColumn` API documented below is the legacy naming used by `@cipherstash/protect`. - -## Installation - -```bash -npm install @cipherstash/schema -# or -yarn add @cipherstash/schema -# or -pnpm add @cipherstash/schema -``` - -## Quick Start - -```typescript -import { csTable, csColumn, buildEncryptConfig } from '@cipherstash/schema' - -// Define your schema -const users = csTable('users', { - email: csColumn('email').freeTextSearch().equality().orderAndRange(), - name: csColumn('name').freeTextSearch(), - age: csColumn('age').orderAndRange(), -}) - -// Build the encryption configuration -const config = buildEncryptConfig(users) -console.log(config) -``` - -## Core Functions - -### `csTable(tableName, columns)` - -Creates a table definition with encrypted columns. - -```typescript -import { csTable, csColumn } from '@cipherstash/schema' - -const users = csTable('users', { - email: csColumn('email'), - name: csColumn('name'), -}) -``` - -### `csColumn(columnName)` - -Creates a column definition with configurable indexes and data types. - -```typescript -import { csColumn } from '@cipherstash/schema' - -const emailColumn = csColumn('email') - .freeTextSearch() // Enable text search - .equality() // Enable exact matching - .orderAndRange() // Enable sorting and range queries - .dataType('string') // Set data type -``` - -### `csValue(valueName)` - -Creates a value definition for nested objects (up to 3 levels deep). - -```typescript -import { csTable, csColumn, csValue } from '@cipherstash/schema' - -const users = csTable('users', { - email: csColumn('email').equality(), - profile: { - name: csValue('profile.name'), - address: { - street: csValue('profile.address.street'), - location: { - coordinates: csValue('profile.address.location.coordinates'), - }, - }, - }, -}) -``` - -### `buildEncryptConfig(...tables)` - -Builds the final encryption configuration from table definitions. - -```typescript -import { buildEncryptConfig } from '@cipherstash/schema' - -const config = buildEncryptConfig(users, orders, products) -``` - -## Index Types - -### Equality Index (`.equality()`) - -Enables exact matching queries. - -```typescript -const emailColumn = csColumn('email').equality() -// SQL equivalent: WHERE email = 'example@example.com' -``` - -### Free Text Search (`.freeTextSearch()`) - -Enables text search with configurable options. - -```typescript -const descriptionColumn = csColumn('description').freeTextSearch({ - tokenizer: { kind: 'ngram', token_length: 3 }, - token_filters: [{ kind: 'downcase' }], - k: 6, - m: 2048, - include_original: true, -}) -// SQL equivalent: WHERE description LIKE '%example%' -``` - -### Order and Range (`.orderAndRange()`) - -Enables sorting and range queries. - -```typescript -const priceColumn = csColumn('price').orderAndRange() -// SQL equivalent: ORDER BY price ASC, WHERE price > 100 -``` - -## Data Types - -Set the data type for a column using `.dataType()`: - -```typescript -const column = csColumn('field') - .dataType('string') // text (default) - .dataType('number') // Javascript number (i.e. integer or float) - .dataType('jsonb') // JSON binary -``` - -## Nested Objects - -Support for nested object encryption (up to 3 levels deep): - -```typescript -const users = csTable('users', { - email: csColumn('email').equality(), - profile: { - name: csValue('profile.name'), - address: { - street: csValue('profile.address.street'), - city: csValue('profile.address.city'), - location: { - coordinates: csValue('profile.address.location.coordinates'), - }, - }, - }, -}) -``` - -> **Note**: Nested objects are not searchable and are not recommended for SQL databases. Use separate columns for searchable fields. - -## Advanced Configuration - -### Custom Token Filters - -```typescript -const column = csColumn('field').equality([ - { kind: 'downcase' } -]) -``` - -### Custom Match Options - -```typescript -const column = csColumn('field').freeTextSearch({ - tokenizer: { kind: 'standard' }, - token_filters: [{ kind: 'downcase' }], - k: 8, - m: 4096, - include_original: false, -}) -``` - -## Type Safety - -The schema builder provides full TypeScript support: - -```typescript -import { csTable, csColumn, type ProtectTableColumn } from '@cipherstash/schema' - -const users = csTable('users', { - email: csColumn('email').equality(), - name: csColumn('name').freeTextSearch(), -} as const) - -// TypeScript will infer the correct types -type UsersTable = typeof users -``` - -## Integration with Protect.js - -While this package can be used standalone, it's typically used through `@cipherstash/protect`: - -```typescript -import { csTable, csColumn } from '@cipherstash/protect' - -const users = csTable('users', { - email: csColumn('email').equality().freeTextSearch(), -}) - -const protectClient = await protect({ - schemas: [users], -}) -``` - -## Generated Configuration - -The `buildEncryptConfig` function generates a configuration object like this: - -```typescript -{ - v: 2, - tables: { - users: { - email: { - cast_as: 'text', - indexes: { - unique: { token_filters: [] }, - match: { - tokenizer: { kind: 'ngram', token_length: 3 }, - token_filters: [{ kind: 'downcase' }], - k: 6, - m: 2048, - include_original: true, - }, - ore: {}, - }, - }, - }, - }, -} -``` - -## Use Cases - -- **Standalone schema building**: When you need to generate encryption configurations outside of Protect.js -- **Custom tooling**: Building tools that work with CipherStash encryption schemas -- **Schema validation**: Validating schema structures before using them with Protect.js -- **Documentation generation**: Creating documentation from schema definitions - -## API Reference - -### `csTable(tableName: string, columns: ProtectTableColumn)` - -Creates a table definition. - -**Parameters:** -- `tableName`: The name of the table in the database -- `columns`: Object defining the columns and their configurations - -**Returns:** `ProtectTable & T` - -### `csColumn(columnName: string)` - -Creates a column definition. - -**Parameters:** -- `columnName`: The name of the column in the database - -**Returns:** `ProtectColumn` - -**Methods:** -- `.dataType(castAs: CastAs)`: Set the data type -- `.equality(tokenFilters?: TokenFilter[])`: Enable equality index -- `.freeTextSearch(opts?: MatchIndexOpts)`: Enable text search -- `.orderAndRange()`: Enable order and range index -- `.searchableJson()`: Enable searchable JSON index - -### `csValue(valueName: string)` - -Creates a value definition for nested objects. - -**Parameters:** -- `valueName`: Dot-separated path to the value (e.g., 'profile.name') - -**Returns:** `ProtectValue` - -**Methods:** -- `.dataType(castAs: CastAs)`: Set the data type - -### `buildEncryptConfig(...tables: ProtectTable[])` - -Builds the encryption configuration. - -**Parameters:** -- `...tables`: Variable number of table definitions - -**Returns:** `EncryptConfig` - -## License - -MIT License - see [LICENSE.md](../../LICENSE.md) for details. diff --git a/packages/schema/__tests__/schema.test.ts b/packages/schema/__tests__/schema.test.ts deleted file mode 100644 index 373f63585..000000000 --- a/packages/schema/__tests__/schema.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { buildEncryptConfig, csColumn, csTable, csValue } from '../src' - -describe('Schema with nested columns', () => { - it('should handle nested column structures in encrypt config', () => { - const users = csTable('users', { - email: csColumn('email').freeTextSearch().equality().orderAndRange(), - address: csColumn('address').freeTextSearch(), - example: { - field: csValue('example.field'), - nested: { - deep: csValue('example.nested.deep'), - }, - }, - } as const) - - const config = buildEncryptConfig(users) - - // Verify basic structure - expect(config).toEqual({ - v: 1, - tables: { - users: expect.any(Object), - }, - }) - - // Verify all columns are present with correct names - const columns = config.tables.users - expect(Object.keys(columns)).toEqual([ - 'email', - 'address', - 'example.field', - 'example.nested.deep', - ]) - - // Verify email column configuration - expect(columns.email).toEqual({ - cast_as: 'string', - indexes: { - match: expect.any(Object), - unique: expect.any(Object), - ore: {}, - }, - }) - - // Verify address column configuration - expect(columns.address).toEqual({ - cast_as: 'string', - indexes: { - match: expect.any(Object), - }, - }) - - // Verify nested field configuration - expect(columns['example.field']).toEqual({ - cast_as: 'string', - indexes: {}, - }) - - // Verify deeply nested field configuration - expect(columns['example.nested.deep']).toEqual({ - cast_as: 'string', - indexes: {}, - }) - }) - - it('should handle multiple tables with nested columns', () => { - const users = csTable('users', { - email: csColumn('email').equality(), - profile: { - name: csValue('profile.name'), - }, - } as const) - - const posts = csTable('posts', { - title: csColumn('title').freeTextSearch(), - metadata: { - tags: csValue('metadata.tags'), - }, - } as const) - - const config = buildEncryptConfig(users, posts) - - // Verify both tables are present - expect(Object.keys(config.tables)).toEqual(['users', 'posts']) - - // Verify users table columns - expect(Object.keys(config.tables.users)).toEqual(['email', 'profile.name']) - expect(config.tables.users.email.indexes).toHaveProperty('unique') - - // Verify posts table columns - expect(Object.keys(config.tables.posts)).toEqual(['title', 'metadata.tags']) - expect(config.tables.posts.title.indexes).toHaveProperty('match') - }) - - it('should handle complex nested structures with multiple index types', () => { - const complex = csTable('complex', { - id: csColumn('id').equality(), - content: { - text: csValue('content.text'), - metadata: { - tags: csValue('content.metadata.tags'), - stats: { - views: csValue('content.metadata.stats.views'), - }, - }, - }, - } as const) - - const config = buildEncryptConfig(complex) - - // Verify all columns are present - expect(Object.keys(config.tables.complex)).toEqual([ - 'id', - 'content.text', - 'content.metadata.tags', - 'content.metadata.stats.views', - ]) - - // Verify complex nested column with multiple indexes - expect(config.tables.complex['content.metadata.tags']).toEqual({ - cast_as: 'string', - indexes: {}, - }) - - // Verify deeply nested column with order and range - expect(config.tables.complex['content.metadata.stats.views']).toEqual({ - cast_as: 'string', - indexes: {}, - }) - }) - - // NOTE: Leaving this test commented out until stevec indexing for JSON is supported. - /*it('should handle ste_vec index for JSON columns', () => { - const users = csTable('users', { - json: csColumn('json').dataType('jsonb').searchableJson(), - } as const) - - const config = buildEncryptConfig(users) - - expect(config.tables.users.json.indexes).toHaveProperty('ste_vec') - expect(config.tables.users.json.indexes.ste_vec?.prefix).toEqual( - 'users/json', - ) - })*/ -}) diff --git a/packages/schema/__tests__/searchable-json.test.ts b/packages/schema/__tests__/searchable-json.test.ts deleted file mode 100644 index 8f14ec7cb..000000000 --- a/packages/schema/__tests__/searchable-json.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { buildEncryptConfig, csColumn, csTable } from '../src/index' - -describe('searchableJson()', () => { - it('sets cast_as to json and ste_vec marker on column build', () => { - const column = csColumn('metadata').searchableJson() - const config = column.build() - - expect(config.cast_as).toBe('json') - expect(config.indexes.ste_vec?.prefix).toBe('enabled') - }) - - it('is chainable', () => { - const column = csColumn('metadata') - expect(column.searchableJson()).toBe(column) - }) -}) - -describe('ProtectTable.build() with searchableJson', () => { - it('transforms prefix to table/column format', () => { - const users = csTable('users', { - metadata: csColumn('metadata').searchableJson(), - }) - const built = users.build() - - expect(built.columns.metadata.cast_as).toBe('json') - expect(built.columns.metadata.indexes.ste_vec?.prefix).toBe( - 'users/metadata', - ) - }) -}) - -describe('buildEncryptConfig with searchableJson', () => { - it('emits ste_vec index with table/column prefix', () => { - const users = csTable('users', { - metadata: csColumn('metadata').searchableJson(), - }) - - const config = buildEncryptConfig(users) - - expect(config.tables.users.metadata.cast_as).toBe('json') - expect(config.tables.users.metadata.indexes.ste_vec?.prefix).toBe( - 'users/metadata', - ) - }) -}) diff --git a/packages/schema/package.json b/packages/schema/package.json deleted file mode 100644 index f718c249f..000000000 --- a/packages/schema/package.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "@cipherstash/schema", - "version": "3.0.2-rc.0", - "description": "CipherStash schema builder for TypeScript", - "keywords": [ - "encrypted", - "protect", - "schema", - "builder" - ], - "bugs": { - "url": "https://github.com/cipherstash/stack/issues" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/cipherstash/stack.git", - "directory": "packages/schema" - }, - "license": "MIT", - "author": "CipherStash ", - "type": "module", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" - } - }, - "files": [ - "dist", - "README.md" - ], - "scripts": { - "build": "tsup", - "dev": "tsup --watch", - "test": "vitest run", - "release": "tsup" - }, - "devDependencies": { - "tsup": "catalog:repo", - "typescript": "catalog:repo", - "vitest": "catalog:repo" - }, - "dependencies": { - "zod": "^3.25.76" - }, - "publishConfig": { - "access": "public" - } -} diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts deleted file mode 100644 index 1b61526a3..000000000 --- a/packages/schema/src/index.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { z } from 'zod' - -// ------------------------ -// Zod schemas -// ------------------------ - -/** - * Allowed cast types for CipherStash schema fields. - * - * **Possible values:** - * - `"bigint"` - * - `"boolean"` - * - `"date"` - * - `"number"` - * - `"string"` - * - `"json"` - * - * @remarks - * This is a Zod enum used at runtime to validate schema definitions. - * Use {@link CastAs} when typing your own code. - */ -export const castAsEnum = z - .enum(['bigint', 'boolean', 'date', 'number', 'string', 'json']) - .default('string') - -const tokenFilterSchema = z.object({ - kind: z.literal('downcase'), -}) - -const tokenizerSchema = z - .union([ - z.object({ - kind: z.literal('standard'), - }), - z.object({ - kind: z.literal('ngram'), - token_length: z.number(), - }), - ]) - .default({ kind: 'ngram', token_length: 3 }) - .optional() - -const oreIndexOptsSchema = z.object({}) - -const uniqueIndexOptsSchema = z.object({ - token_filters: z.array(tokenFilterSchema).default([]).optional(), -}) - -const matchIndexOptsSchema = z.object({ - tokenizer: tokenizerSchema, - token_filters: z.array(tokenFilterSchema).default([]).optional(), - k: z.number().default(6).optional(), - m: z.number().default(2048).optional(), - include_original: z.boolean().default(false).optional(), -}) - -const arrayIndexModeSchema = z.union([ - z.literal('all'), - z.literal('none'), - z.object({ - item: z.boolean().optional(), - wildcard: z.boolean().optional(), - position: z.boolean().optional(), - }), -]) - -const steVecIndexOptsSchema = z.object({ - prefix: z.string(), - array_index_mode: arrayIndexModeSchema.optional(), - mode: z.enum(['compat', 'standard']).optional(), -}) - -const indexesSchema = z - .object({ - ore: oreIndexOptsSchema.optional(), - unique: uniqueIndexOptsSchema.optional(), - match: matchIndexOptsSchema.optional(), - ste_vec: steVecIndexOptsSchema.optional(), - }) - .default({}) - -const columnSchema = z - .object({ - cast_as: castAsEnum, - indexes: indexesSchema, - }) - .default({}) - -const tableSchema = z.record(columnSchema).default({}) - -const tablesSchema = z.record(tableSchema).default({}) - -// `v` is locked to `1` because protect-ffi `0.22.0+` rejects any other value at -// `newClient` with `UNSUPPORTED_CONFIG_VERSION`. Enforcing it here means a bad -// hand-rolled config fails at zod-validation time instead of crossing into the -// native module first. -export const encryptConfigSchema = z.object({ - v: z.literal(1), - tables: tablesSchema, -}) - -// ------------------------ -// Type definitions -// ------------------------ - -/** - * Type-safe alias for {@link castAsEnum} used to specify the *unencrypted* data type of a column or value. - * This is important because once encrypted, all data is stored as binary blobs. - * - * @see {@link castAsEnum} for possible values. - */ -export type CastAs = z.infer -export type TokenFilter = z.infer -export type MatchIndexOpts = z.infer -export type SteVecIndexOpts = z.infer -export type UniqueIndexOpts = z.infer -export type OreIndexOpts = z.infer -export type ColumnSchema = z.infer - -export type ProtectTableColumn = { - [key: string]: - | ProtectColumn - | { - [key: string]: - | ProtectValue - | { - [key: string]: - | ProtectValue - | { - [key: string]: ProtectValue - } - } - } -} -export type EncryptConfig = z.infer - -// ------------------------ -// Interface definitions -// ------------------------ -export class ProtectValue { - private valueName: string - private castAsValue: CastAs - - constructor(valueName: string) { - this.valueName = valueName - this.castAsValue = 'string' - } - - /** - * Set or override the cast_as value. - */ - dataType(castAs: CastAs) { - this.castAsValue = castAs - return this - } - - build() { - return { - cast_as: this.castAsValue, - indexes: {}, - } - } - - getName() { - return this.valueName - } -} - -export class ProtectColumn { - private columnName: string - private castAsValue: CastAs - private indexesValue: { - ore?: OreIndexOpts - unique?: UniqueIndexOpts - match?: Required - ste_vec?: SteVecIndexOpts - } = {} - - constructor(columnName: string) { - this.columnName = columnName - this.castAsValue = 'string' - } - - /** - * Set or override the cast_as value. - */ - dataType(castAs: CastAs) { - this.castAsValue = castAs - return this - } - - /** - * Enable ORE indexing (Order-Revealing Encryption). - */ - orderAndRange() { - this.indexesValue.ore = {} - return this - } - - /** - * Enable an Exact index. Optionally pass tokenFilters. - */ - equality(tokenFilters?: TokenFilter[]) { - this.indexesValue.unique = { - token_filters: tokenFilters ?? [], - } - return this - } - - /** - * Enable a Match index. Allows passing of custom match options. - */ - freeTextSearch(opts?: MatchIndexOpts) { - // Provide defaults - this.indexesValue.match = { - tokenizer: opts?.tokenizer ?? { kind: 'ngram', token_length: 3 }, - token_filters: opts?.token_filters ?? [ - { - kind: 'downcase', - }, - ], - k: opts?.k ?? 6, - m: opts?.m ?? 2048, - include_original: opts?.include_original ?? true, - } - return this - } - - /** - * Configure this column for searchable encrypted JSON. - * Enables path queries ($.user.email) and containment queries ({ role: 'admin' }). - * Automatically sets cast_as to 'json'. - */ - searchableJson() { - this.castAsValue = 'json' - // `mode: 'standard'` pins the per-entry ordering term to CLLW-ORE (`oc`), - // the only encoding the eql_v2 SQL compares. protect-ffi 0.29 flipped the - // library default to `compat` (CLLW-OPE, `op`) for EQL v3; without the pin - // every v2 containment query silently matches nothing — and existing v2 - // rows encrypted under `standard` are not cross-comparable with `compat` - // anyway, so this also keeps the v2 wire format byte-stable. - this.indexesValue.ste_vec = { - prefix: 'enabled', - array_index_mode: 'all', - mode: 'standard', - } - return this - } - - build() { - return { - cast_as: this.castAsValue, - indexes: this.indexesValue, - } - } - - getName() { - return this.columnName - } -} - -interface TableDefinition { - tableName: string - columns: Record -} - -export class ProtectTable { - constructor( - public readonly tableName: string, - private readonly columnBuilders: T, - ) {} - - /** - * Build a TableDefinition object: tableName + built column configs. - */ - build(): TableDefinition { - const builtColumns: Record = {} - - const processColumn = ( - builder: - | ProtectColumn - | Record< - string, - | ProtectValue - | Record< - string, - | ProtectValue - | Record> - > - >, - colName: string, - ) => { - if (builder instanceof ProtectColumn) { - const builtColumn = builder.build() - - // Hanlde building the ste_vec index for JSON columns so users don't have to pass the prefix. - if ( - builtColumn.cast_as === 'json' && - builtColumn.indexes.ste_vec?.prefix === 'enabled' - ) { - builtColumns[colName] = { - ...builtColumn, - indexes: { - ...builtColumn.indexes, - ste_vec: { - ...builtColumn.indexes.ste_vec, - prefix: `${this.tableName}/${colName}`, - }, - }, - } - } else { - builtColumns[colName] = builtColumn - } - } else { - for (const [key, value] of Object.entries(builder)) { - if (value instanceof ProtectValue) { - builtColumns[value.getName()] = value.build() - } else { - processColumn(value, key) - } - } - } - } - - for (const [colName, builder] of Object.entries(this.columnBuilders)) { - processColumn(builder, colName) - } - - return { - tableName: this.tableName, - columns: builtColumns, - } - } -} - -// ------------------------ -// User facing functions -// ------------------------ -export function csTable( - tableName: string, - columns: T, -): ProtectTable & T { - const tableBuilder = new ProtectTable(tableName, columns) as ProtectTable & - T - - for (const [colName, colBuilder] of Object.entries(columns)) { - ;(tableBuilder as ProtectTableColumn)[colName] = colBuilder - } - - return tableBuilder -} - -export function csColumn(columnName: string) { - return new ProtectColumn(columnName) -} - -export function csValue(valueName: string) { - return new ProtectValue(valueName) -} - -// ------------------------ -// Internal functions -// ------------------------ -export function buildEncryptConfig( - ...protectTables: Array> -): EncryptConfig { - const config: EncryptConfig = { - v: 1, - tables: {}, - } - - for (const tb of protectTables) { - const tableDef = tb.build() - config.tables[tableDef.tableName] = tableDef.columns - } - - return config -} diff --git a/packages/schema/tsconfig.json b/packages/schema/tsconfig.json deleted file mode 100644 index 6da81c927..000000000 --- a/packages/schema/tsconfig.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "compilerOptions": { - // Enable latest features - "lib": ["ESNext", "DOM"], - "target": "ESNext", - "module": "ESNext", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - "esModuleInterop": true, - - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - } -} diff --git a/packages/schema/tsup.config.ts b/packages/schema/tsup.config.ts deleted file mode 100644 index 0ced0a354..000000000 --- a/packages/schema/tsup.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'tsup' - -export default defineConfig({ - entry: ['src/index.ts'], - format: ['cjs', 'esm'], - sourcemap: true, - dts: true, -}) diff --git a/packages/stack-drizzle/README.md b/packages/stack-drizzle/README.md index 841726a30..d261a97d1 100644 --- a/packages/stack-drizzle/README.md +++ b/packages/stack-drizzle/README.md @@ -71,19 +71,19 @@ npm install @cipherstash/stack @cipherstash/stack-drizzle drizzle-orm Full guide: [Drizzle quickstart →][drizzle-docs] -## Full example (EQL v3, the `/v3` subpath) +## Full example (EQL v3) Each encrypted column is a concrete `public.eql_v3_*` Postgres domain whose query capabilities are fixed by the `types.*` factory you choose — no per-column config object: ```ts import { pgTable, integer } from 'drizzle-orm/pg-core' -import { EncryptionV3 } from '@cipherstash/stack/v3' +import { Encryption } from '@cipherstash/stack/v3' import { types, - extractEncryptionSchemaV3, - createEncryptionOperatorsV3, -} from '@cipherstash/stack-drizzle/v3' + extractEncryptionSchema, + createEncryptionOperators, +} from '@cipherstash/stack-drizzle' const users = pgTable('users', { id: integer('id').primaryKey().generatedAlwaysAsIdentity(), @@ -91,9 +91,9 @@ const users = pgTable('users', { age: types.IntegerOrd('age'), // equality + order/range }) -const schema = extractEncryptionSchemaV3(users) -const client = await EncryptionV3({ schemas: [schema] }) -const ops = createEncryptionOperatorsV3(client) +const schema = extractEncryptionSchema(users) +const client = await Encryption({ schemas: [schema] }) +const ops = createEncryptionOperators(client) // Insert — encrypt models first (bulk helpers batch key operations // through ZeroKMS instead of one round trip per row) @@ -107,7 +107,11 @@ if (!enc.failure) await db.insert(users).values(enc.data) const rows = await db .select() .from(users) - .where(await ops.eq(users.email, 'alice@example.com')) + .where(await ops.and( + ops.matches(users.email, 'alice'), // free-text token match over ciphertext + ops.between(users.age, 18, 65), + )) + .orderBy(ops.asc(users.age)) // Decrypt after select const dec = await client.bulkDecryptModels(rows, schema) @@ -128,7 +132,7 @@ indexes up like any others: ```ts import { integer, pgTable } from 'drizzle-orm/pg-core' -import { encryptedIndexes, types } from '@cipherstash/stack-drizzle/v3' +import { encryptedIndexes, types } from '@cipherstash/stack-drizzle' export const users = pgTable( 'users', @@ -160,19 +164,6 @@ key revocation, and [identity-bound decryption][identity] (lock contexts) work w database ever holding a secret. Runs on plain PostgreSQL, Supabase, and RDS/Aurora; the SQL install needs no superuser. -## EQL v2 (package root) — legacy - -The v2 integration predates the typed v3 domains and is kept for existing projects. New -projects should use v3 above. - -```ts -import { encryptedType, extractEncryptionSchema, createEncryptionOperators } from '@cipherstash/stack-drizzle' -import { Encryption } from '@cipherstash/stack' -``` - -`encryptedType` defines an `eql_v2_encrypted` column; `createEncryptionOperators` returns -query operators (`eq`, `like`, `gt`, `inArray`, …) that transparently encrypt search values. - ## Docs - [Drizzle integration guide →][drizzle-docs] diff --git a/packages/stack-drizzle/__tests__/v3/bigint.test.ts b/packages/stack-drizzle/__tests__/bigint.test.ts similarity index 94% rename from packages/stack-drizzle/__tests__/v3/bigint.test.ts rename to packages/stack-drizzle/__tests__/bigint.test.ts index e31a6a4e2..7cfbf9f3d 100644 --- a/packages/stack-drizzle/__tests__/v3/bigint.test.ts +++ b/packages/stack-drizzle/__tests__/bigint.test.ts @@ -1,12 +1,12 @@ import { type SQL, sql } from 'drizzle-orm' import { integer, PgDialect, pgTable } from 'drizzle-orm/pg-core' import { describe, expect, it, vi } from 'vitest' -import { getEqlV3Column, isEqlV3Column } from '../../src/v3/column' +import { getEqlV3Column, isEqlV3Column } from '../src/column' import { - createEncryptionOperatorsV3, + createEncryptionOperators, EncryptionOperatorError, -} from '../../src/v3/operators' -import { types } from '../../src/v3/types' +} from '../src/operators' +import { types } from '../src/types' // A representative query TERM — what `client.encryptQuery` returns for a bigint // operand: a ciphertext-free term, deliberately NOT a plaintext bigint. @@ -25,7 +25,7 @@ function chainable(result: unknown) { function setup() { const encryptQuery = vi.fn(() => chainable(Promise.resolve({ data: TERM }))) - const ops = createEncryptionOperatorsV3({ encryptQuery }) + const ops = createEncryptionOperators({ encryptQuery }) const dialect = new PgDialect() const render = (s: SQL) => dialect.sqlToQuery(s) return { ops, encryptQuery, render } diff --git a/packages/stack-drizzle/__tests__/v3/codec.test.ts b/packages/stack-drizzle/__tests__/codec.test.ts similarity index 98% rename from packages/stack-drizzle/__tests__/v3/codec.test.ts rename to packages/stack-drizzle/__tests__/codec.test.ts index 3049c2c2a..6d44985a4 100644 --- a/packages/stack-drizzle/__tests__/v3/codec.test.ts +++ b/packages/stack-drizzle/__tests__/codec.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { EqlV3CodecError, v3FromDriver, v3ToDriver } from '../../src/v3/codec' +import { EqlV3CodecError, v3FromDriver, v3ToDriver } from '../src/codec' // A realistic `public.eql_v3_text_eq` envelope: schema version, table/column // identifier, mp_base85 ciphertext, HMAC term. The trivial `{v:1,c:'ct'}` diff --git a/packages/stack-drizzle/__tests__/v3/column.test.ts b/packages/stack-drizzle/__tests__/column.test.ts similarity index 99% rename from packages/stack-drizzle/__tests__/v3/column.test.ts rename to packages/stack-drizzle/__tests__/column.test.ts index 9ca47384d..dfca7f9b8 100644 --- a/packages/stack-drizzle/__tests__/v3/column.test.ts +++ b/packages/stack-drizzle/__tests__/column.test.ts @@ -12,7 +12,7 @@ import { getEqlV3Column, isEqlV3Column, makeEqlV3Column, -} from '../../src/v3/column' +} from '../src/column' describe('makeEqlV3Column', () => { it('sets dataType() to the BARE eql_v3 domain (no schema qualifier)', () => { diff --git a/packages/stack-drizzle/__tests__/drizzle-operators-bigint.test.ts b/packages/stack-drizzle/__tests__/drizzle-operators-bigint.test.ts deleted file mode 100644 index e71e47622..000000000 --- a/packages/stack-drizzle/__tests__/drizzle-operators-bigint.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { EncryptionClient } from '@cipherstash/stack/encryption' -import { PgDialect, pgTable } from 'drizzle-orm/pg-core' -import { describe, expect, it, vi } from 'vitest' -import { createEncryptionOperators, encryptedType } from '../src/index.js' - -// Regression coverage for the `bigint` (int8) plaintext path through the v3 -// Drizzle operators. Before the fix, `toPlaintext` fell through to -// `String(value)`, so `ops.eq(bigintCol, 1n)` was encrypted as the TEXT "1" and -// silently mismatched the column's bigint domain. The operators must forward a -// native `bigint` to `encryptQuery` (whose term type is `Plaintext`), so -// protect-ffi 0.28 encrypts it against the int8 domain and bounds-checks it. - -const ENCRYPTED_VALUE = '{"v":"encrypted-value"}' - -function setup() { - const encryptQuery = vi.fn(async (termsOrValue: unknown) => - Array.isArray(termsOrValue) - ? { data: termsOrValue.map(() => ENCRYPTED_VALUE) } - : { data: ENCRYPTED_VALUE }, - ) - const client = { encryptQuery } as unknown as EncryptionClient - const encryptionOps = createEncryptionOperators(client) - const dialect = new PgDialect() - return { encryptQuery, encryptionOps, dialect } -} - -const accounts = pgTable('accounts', { - balance: encryptedType('balance', { - dataType: 'bigint', - equality: true, - orderAndRange: true, - }), -}) - -describe('Drizzle v3 operators preserve bigint plaintext (int8 columns)', () => { - it('eq forwards a native bigint to encryptQuery, not a String()-coerced value', async () => { - const { encryptQuery, encryptionOps } = setup() - - await encryptionOps.eq(accounts.balance, 1n) - - expect(encryptQuery).toHaveBeenCalledTimes(1) - const terms = encryptQuery.mock.calls[0]?.[0] as Array<{ value: unknown }> - expect(terms).toHaveLength(1) - expect(typeof terms[0]?.value).toBe('bigint') - expect(terms[0]?.value).toBe(1n) - // The pre-fix bug: a stringified bigint. - expect(terms[0]?.value).not.toBe('1') - }) - - it('preserves i64 boundary magnitudes losslessly (beyond Number.MAX_SAFE_INTEGER)', async () => { - const { encryptQuery, encryptionOps } = setup() - const big = 9223372036854775807n // i64::MAX - - await encryptionOps.gt(accounts.balance, big) - - const terms = encryptQuery.mock.calls[0]?.[0] as Array<{ value: unknown }> - expect(terms[0]?.value).toBe(big) - }) -}) diff --git a/packages/stack-drizzle/__tests__/drizzle-operators-jsonb.test.ts b/packages/stack-drizzle/__tests__/drizzle-operators-jsonb.test.ts deleted file mode 100644 index 47ab6bce0..000000000 --- a/packages/stack-drizzle/__tests__/drizzle-operators-jsonb.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import type { EncryptionClient } from '@cipherstash/stack/encryption' -import { PgDialect, pgTable } from 'drizzle-orm/pg-core' -import { describe, expect, it, vi } from 'vitest' -import { - createEncryptionOperators, - EncryptionOperatorError, - encryptedType, -} from '../src/index.js' - -const ENCRYPTED_VALUE = '{"v":"encrypted-value"}' - -function createMockEncryptionClient() { - const encryptQuery = vi.fn(async (termsOrValue: unknown) => { - if (Array.isArray(termsOrValue)) { - return { data: termsOrValue.map(() => ENCRYPTED_VALUE) } - } - return { data: ENCRYPTED_VALUE } - }) - - return { - client: { encryptQuery } as unknown as EncryptionClient, - encryptQuery, - } -} - -function setup() { - const { client, encryptQuery } = createMockEncryptionClient() - const encryptionOps = createEncryptionOperators(client) - const dialect = new PgDialect() - return { client, encryptQuery, encryptionOps, dialect } -} - -const docsTable = pgTable('json_docs', { - metadata: encryptedType>('metadata', { - dataType: 'json', - searchableJson: true, - }), - noJsonConfig: encryptedType('no_json_config', { - equality: true, - }), -}) - -describe('createEncryptionOperators JSONB selector typing', () => { - it('casts jsonbPathQueryFirst selector params to eql_v2_encrypted', async () => { - const { encryptQuery, encryptionOps, dialect } = setup() - - const condition = await encryptionOps.jsonbPathQueryFirst( - docsTable.metadata, - '$.profile.email', - ) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toMatch( - /eql_v2\.jsonb_path_query_first\([^,]+,\s*\$\d+::eql_v2_encrypted\)/, - ) - expect(query.params).toHaveLength(1) - expect(query.params[0]).toContain('encrypted-value') - expect(encryptQuery).toHaveBeenCalledTimes(1) - expect(encryptQuery.mock.calls[0]?.[0]).toMatchObject([ - { queryType: 'steVecSelector' }, - ]) - }) - - it('casts jsonbPathExists selector params to eql_v2_encrypted', async () => { - const { encryptQuery, encryptionOps, dialect } = setup() - - const condition = await encryptionOps.jsonbPathExists( - docsTable.metadata, - '$.profile.email', - ) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toMatch( - /eql_v2\.jsonb_path_exists\([^,]+,\s*\$\d+::eql_v2_encrypted\)/, - ) - expect(query.params).toHaveLength(1) - expect(query.params[0]).toContain('encrypted-value') - expect(encryptQuery).toHaveBeenCalledTimes(1) - expect(encryptQuery.mock.calls[0]?.[0]).toMatchObject([ - { queryType: 'steVecSelector' }, - ]) - }) - - it('casts jsonbGet selector params to eql_v2_encrypted', async () => { - const { encryptQuery, encryptionOps, dialect } = setup() - - const condition = await encryptionOps.jsonbGet( - docsTable.metadata, - '$.profile.email', - ) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toMatch(/->\s*\$\d+::eql_v2_encrypted/) - expect(query.params).toHaveLength(1) - expect(query.params[0]).toContain('encrypted-value') - expect(encryptQuery).toHaveBeenCalledTimes(1) - expect(encryptQuery.mock.calls[0]?.[0]).toMatchObject([ - { queryType: 'steVecSelector' }, - ]) - }) -}) - -describe('JSONB operator error paths', () => { - it('throws EncryptionOperatorError when column lacks searchableJson config', () => { - const { encryptionOps } = setup() - - expect(() => - encryptionOps.jsonbPathQueryFirst(docsTable.noJsonConfig, '$.path'), - ).toThrow(EncryptionOperatorError) - - expect(() => - encryptionOps.jsonbPathQueryFirst(docsTable.noJsonConfig, '$.path'), - ).toThrow(/searchableJson/) - }) - - it('throws EncryptionOperatorError for jsonbPathExists without searchableJson', () => { - const { encryptionOps } = setup() - - expect(() => - encryptionOps.jsonbPathExists(docsTable.noJsonConfig, '$.path'), - ).toThrow(EncryptionOperatorError) - }) - - it('throws EncryptionOperatorError for jsonbGet without searchableJson', () => { - const { encryptionOps } = setup() - - expect(() => - encryptionOps.jsonbGet(docsTable.noJsonConfig, '$.path'), - ).toThrow(EncryptionOperatorError) - }) - - it('error includes column name and operator context', () => { - const { encryptionOps } = setup() - - try { - encryptionOps.jsonbPathQueryFirst(docsTable.noJsonConfig, '$.path') - expect.fail('Should have thrown') - } catch (error) { - expect(error).toBeInstanceOf(EncryptionOperatorError) - const opError = error as EncryptionOperatorError - expect(opError.context?.columnName).toBe('no_json_config') - expect(opError.context?.operator).toBe('jsonbPathQueryFirst') - } - }) -}) - -describe('JSONB batched operations', () => { - it('batches jsonbPathQueryFirst and jsonbGet in encryptionOps.and()', async () => { - const { encryptQuery, encryptionOps, dialect } = setup() - - const condition = await encryptionOps.and( - encryptionOps.jsonbPathQueryFirst(docsTable.metadata, '$.profile.email'), - encryptionOps.jsonbGet(docsTable.metadata, '$.profile.name'), - ) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toContain('eql_v2.jsonb_path_query_first') - expect(query.sql).toContain('->') - // Verify batch encryption happened (at least one call with 2 terms) - expect( - encryptQuery.mock.calls.some( - (call: unknown[]) => Array.isArray(call[0]) && call[0].length === 2, - ), - ).toBe(true) - }) - - it('batches jsonbPathExists and jsonbPathQueryFirst in encryptionOps.or()', async () => { - const { encryptQuery, encryptionOps, dialect } = setup() - - const condition = await encryptionOps.or( - encryptionOps.jsonbPathExists(docsTable.metadata, '$.profile.email'), - encryptionOps.jsonbPathQueryFirst(docsTable.metadata, '$.profile.name'), - ) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toContain('eql_v2.jsonb_path_exists') - expect(query.sql).toContain('eql_v2.jsonb_path_query_first') - // Verify batch encryption happened (at least one call with 2 terms) - expect( - encryptQuery.mock.calls.some( - (call: unknown[]) => Array.isArray(call[0]) && call[0].length === 2, - ), - ).toBe(true) - }) - - it('generates SQL combining conditions with AND', async () => { - const { encryptionOps, dialect } = setup() - - const condition = await encryptionOps.and( - encryptionOps.jsonbPathQueryFirst(docsTable.metadata, '$.a'), - encryptionOps.jsonbPathExists(docsTable.metadata, '$.b'), - ) - const query = dialect.sqlToQuery(condition) - - // AND combines conditions - expect(query.sql).toContain(' and ') - }) - - it('generates SQL combining conditions with OR', async () => { - const { encryptionOps, dialect } = setup() - - const condition = await encryptionOps.or( - encryptionOps.jsonbPathQueryFirst(docsTable.metadata, '$.a'), - encryptionOps.jsonbPathExists(docsTable.metadata, '$.b'), - ) - const query = dialect.sqlToQuery(condition) - - // OR combines conditions - expect(query.sql).toContain(' or ') - }) -}) diff --git a/packages/stack-drizzle/__tests__/v3/exports.test.ts b/packages/stack-drizzle/__tests__/exports.test.ts similarity index 91% rename from packages/stack-drizzle/__tests__/v3/exports.test.ts rename to packages/stack-drizzle/__tests__/exports.test.ts index 2b3b41f74..4a32d0c3f 100644 --- a/packages/stack-drizzle/__tests__/v3/exports.test.ts +++ b/packages/stack-drizzle/__tests__/exports.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import * as barrel from '../../src/v3/index.js' +import * as barrel from '../src/index.js' // Exhaustive, so ADDING an export fails here too. The previous version asserted // only four names, leaving the codec/column re-exports unpinned and letting a @@ -7,9 +7,9 @@ import * as barrel from '../../src/v3/index.js' const PUBLIC_SURFACE = [ 'EncryptionOperatorError', 'EqlV3CodecError', - 'createEncryptionOperatorsV3', + 'createEncryptionOperators', 'encryptedIndexes', - 'extractEncryptionSchemaV3', + 'extractEncryptionSchema', 'getEqlV3Column', 'isEqlV3Column', 'makeEqlV3Column', diff --git a/packages/stack-drizzle/__tests__/v3/indexes.test.ts b/packages/stack-drizzle/__tests__/indexes.test.ts similarity index 98% rename from packages/stack-drizzle/__tests__/v3/indexes.test.ts rename to packages/stack-drizzle/__tests__/indexes.test.ts index d81a9bc8a..a3d57be21 100644 --- a/packages/stack-drizzle/__tests__/v3/indexes.test.ts +++ b/packages/stack-drizzle/__tests__/indexes.test.ts @@ -6,8 +6,8 @@ import { pgTable, } from 'drizzle-orm/pg-core' import { describe, expect, it } from 'vitest' -import { encryptedIndexes } from '../../src/v3/indexes' -import { types } from '../../src/v3/types' +import { encryptedIndexes } from '../src/indexes' +import { types } from '../src/types' // #753: the integration emitted the encrypted operators but no index DDL, so // encrypted predicates sequential-scanned by default. `encryptedIndexes` diff --git a/packages/stack-drizzle/__tests__/v3/operators.test-d.ts b/packages/stack-drizzle/__tests__/operators.test-d.ts similarity index 84% rename from packages/stack-drizzle/__tests__/v3/operators.test-d.ts rename to packages/stack-drizzle/__tests__/operators.test-d.ts index 7c04db648..600a62d63 100644 --- a/packages/stack-drizzle/__tests__/v3/operators.test-d.ts +++ b/packages/stack-drizzle/__tests__/operators.test-d.ts @@ -4,14 +4,14 @@ import type { EncryptionClient } from '@cipherstash/stack/encryption' import type { EncryptionError } from '@cipherstash/stack/errors' import type { LockContext } from '@cipherstash/stack/identity' import type { EncryptedQueryResult } from '@cipherstash/stack/types' -import type { EncryptionV3 } from '@cipherstash/stack/v3' +import type { AnyV3Table, EncryptionClientFor } from '@cipherstash/stack/v3' import { describe, expectTypeOf, it } from 'vitest' -import { createEncryptionOperatorsV3 } from '../../src/v3/index.js' +import { createEncryptionOperators } from '../src/index.js' /** - * Static regression guard for M1: `createEncryptionOperatorsV3` must accept the + * Static regression guard for M1: `createEncryptionOperators` must accept the * `TypedEncryptionClient` that `EncryptionV3` resolves to — the documented - * `createEncryptionOperatorsV3(await EncryptionV3({ schemas }))` usage — as well + * `createEncryptionOperators(await EncryptionV3({ schemas }))` usage — as well * as the nominal `EncryptionClient` and a hand-rolled `{ encryptQuery }` double, * none requiring a cast. Typing the parameter to `EncryptionClient` (the * original bug) makes the first call below a compile error, which this suite @@ -20,8 +20,8 @@ import { createEncryptionOperatorsV3 } from '../../src/v3/index.js' * forms; the doubles model that. Lives in a `*.test-d.ts` so it is inside the * existing typecheck scope without dragging the loose-typed runtime suites in. */ -describe('createEncryptionOperatorsV3 - client parameter (M1)', () => { - type V3Client = Awaited> +describe('createEncryptionOperators - client parameter (M1)', () => { + type V3Client = EncryptionClientFor // A query operation resolving `Result` — the surface the factory drives. type QueryOp = { @@ -31,11 +31,11 @@ describe('createEncryptionOperatorsV3 - client parameter (M1)', () => { } it('accepts the client EncryptionV3 returns with no cast', () => { - expectTypeOf(createEncryptionOperatorsV3).toBeCallableWith({} as V3Client) + expectTypeOf(createEncryptionOperators).toBeCallableWith({} as V3Client) }) it('still accepts the nominal EncryptionClient', () => { - expectTypeOf(createEncryptionOperatorsV3).toBeCallableWith( + expectTypeOf(createEncryptionOperators).toBeCallableWith( {} as EncryptionClient, ) }) @@ -57,7 +57,7 @@ describe('createEncryptionOperatorsV3 - client parameter (M1)', () => { // the operand-client contract, so a structural double must supply it too. encrypt: (_value: never, _opts: never): QueryOp => ({}) as never, } - expectTypeOf(createEncryptionOperatorsV3).toBeCallableWith(double) + expectTypeOf(createEncryptionOperators).toBeCallableWith(double) }) it('rejects an { encryptQuery } double that resolves `unknown` (the erasure regression)', () => { @@ -76,6 +76,6 @@ describe('createEncryptionOperatorsV3 - client parameter (M1)', () => { } // @ts-expect-error — `encryptQuery` resolving `unknown` does not satisfy the // factory's `ChainableOperation` client contract. - createEncryptionOperatorsV3(erased) + createEncryptionOperators(erased) }) }) diff --git a/packages/stack-drizzle/__tests__/v3/operators.test.ts b/packages/stack-drizzle/__tests__/operators.test.ts similarity index 97% rename from packages/stack-drizzle/__tests__/v3/operators.test.ts rename to packages/stack-drizzle/__tests__/operators.test.ts index 3a5ccc120..bc2456173 100644 --- a/packages/stack-drizzle/__tests__/v3/operators.test.ts +++ b/packages/stack-drizzle/__tests__/operators.test.ts @@ -17,13 +17,13 @@ import { } from 'drizzle-orm' import { integer, PgDialect, pgTable } from 'drizzle-orm/pg-core' import { describe, expect, it, vi } from 'vitest' -import { makeEqlV3Column } from '../../src/v3/column' +import { makeEqlV3Column } from '../src/column' import { - createEncryptionOperatorsV3, + createEncryptionOperators, EncryptionOperatorError, -} from '../../src/v3/operators' -import { extractEncryptionSchemaV3 } from '../../src/v3/schema-extraction' -import { types } from '../../src/v3/types' +} from '../src/operators' +import { extractEncryptionSchema } from '../src/schema-extraction' +import { types } from '../src/types' // A query TERM (from `encryptQuery`), not a storage envelope: it carries index // terms but NO ciphertext `c` — that is the whole point of the encrypt → @@ -75,7 +75,7 @@ function setup(termImpl: (value?: unknown) => unknown = () => TERM) { // The factory's `client` parameter is the structural `{ encryptQuery }` // surface, so this hand-rolled double satisfies it with no cast. const client = { encryptQuery } - const ops = createEncryptionOperatorsV3(client, { lockContext, audit }) + const ops = createEncryptionOperators(client, { lockContext, audit }) const dialect = new PgDialect() const render = (s: unknown) => dialect.sqlToQuery(s as SQL) return { ops, encryptQuery, render } @@ -138,7 +138,7 @@ const users = pgTable('users', { flag: types.Boolean('flag'), }) -describe('createEncryptionOperatorsV3 - equality', () => { +describe('createEncryptionOperators - equality', () => { it.each( equalityDomains, )('%s eq emits the latest two-arg eql_v3.eq with a query-term operand', async (eqlType, spec) => { @@ -198,7 +198,7 @@ describe('createEncryptionOperatorsV3 - equality', () => { await ops.eq(second.age, 37) expect(encryptQuery.mock.calls[1]?.[1]?.table.build()).toEqual( - extractEncryptionSchemaV3(second).build(), + extractEncryptionSchema(second).build(), ) }) @@ -228,7 +228,7 @@ describe('createEncryptionOperatorsV3 - equality', () => { }) }) -describe('createEncryptionOperatorsV3 - comparison & range', () => { +describe('createEncryptionOperators - comparison & range', () => { it.each([ ['gt', 'eql_v3.gt'], ['gte', 'eql_v3.gte'], @@ -355,7 +355,7 @@ describe('createEncryptionOperatorsV3 - comparison & range', () => { }) }) -describe('createEncryptionOperatorsV3 - free-text match', () => { +describe('createEncryptionOperators - free-text match', () => { it.each( matchDomains, )('%s matches emits latest eql_v3.matches with a query-term operand', async (eqlType, spec) => { @@ -413,7 +413,7 @@ describe('createEncryptionOperatorsV3 - free-text match', () => { }) }) -describe('createEncryptionOperatorsV3 - JSON containment', () => { +describe('createEncryptionOperators - JSON containment', () => { const JSON_TYPE = 'public.eql_v3_json_search' // json has no `eql_v3.matches` overload: containment is the `@>` operator, @@ -476,7 +476,7 @@ describe('createEncryptionOperatorsV3 - JSON containment', () => { }) }) -describe('createEncryptionOperatorsV3 - JSON selectors', () => { +describe('createEncryptionOperators - JSON selectors', () => { const JSON_TYPE = 'public.eql_v3_json_search' const column = matrixColumn(JSON_TYPE) const VALUE_SELECTOR = { sv: [{ s: 'value-selector' }] } @@ -565,7 +565,7 @@ describe('createEncryptionOperatorsV3 - JSON selectors', () => { }) }) -describe('createEncryptionOperatorsV3 - domains with no scalar query', () => { +describe('createEncryptionOperators - domains with no scalar query', () => { it.each(nonScalarQueryDomains)('%s eq throws', async (eqlType, spec) => { const { ops } = setup() await expect( @@ -588,7 +588,7 @@ describe('createEncryptionOperatorsV3 - domains with no scalar query', () => { }) }) -describe('createEncryptionOperatorsV3 - array, ordering, combinators', () => { +describe('createEncryptionOperators - array, ordering, combinators', () => { it('inArray ORs one eq term per value; empty array throws', async () => { const { ops, render } = setup() const q = render(await ops.inArray(users.nickname, ['ada', 'grace'])) @@ -784,7 +784,7 @@ describe('createEncryptionOperatorsV3 - array, ordering, combinators', () => { }) }) -describe('createEncryptionOperatorsV3 - gating errors', () => { +describe('createEncryptionOperators - gating errors', () => { it('wraps encryption failures with operator context', async () => { const { ops, encryptQuery } = setup() encryptQuery.mockReturnValueOnce( diff --git a/packages/stack-drizzle/__tests__/v3/schema-extraction.test.ts b/packages/stack-drizzle/__tests__/schema-extraction.test.ts similarity index 82% rename from packages/stack-drizzle/__tests__/v3/schema-extraction.test.ts rename to packages/stack-drizzle/__tests__/schema-extraction.test.ts index e3767fcc0..4c9c32a6d 100644 --- a/packages/stack-drizzle/__tests__/v3/schema-extraction.test.ts +++ b/packages/stack-drizzle/__tests__/schema-extraction.test.ts @@ -1,10 +1,10 @@ import { encryptedTable, types as v3Types } from '@cipherstash/stack/eql/v3' import { integer, pgTable } from 'drizzle-orm/pg-core' import { describe, expect, it } from 'vitest' -import { extractEncryptionSchemaV3 } from '../../src/v3/schema-extraction' -import { types } from '../../src/v3/types' +import { extractEncryptionSchema } from '../src/schema-extraction' +import { types } from '../src/types' -describe('extractEncryptionSchemaV3', () => { +describe('extractEncryptionSchema', () => { it('rebuilds an equivalent eql/v3 encryptedTable from every drizzle v3 factory', () => { const drizzleColumns = Object.fromEntries( Object.entries(types).map(([factoryName, factory]) => [ @@ -19,7 +19,7 @@ describe('extractEncryptionSchemaV3', () => { ]), ) - const extracted = extractEncryptionSchemaV3( + const extracted = extractEncryptionSchema( pgTable('users', drizzleColumns as never), ) const authored = encryptedTable('users', authoredColumns as never) @@ -34,7 +34,7 @@ describe('extractEncryptionSchemaV3', () => { age: types.IntegerOrd('age'), }) - const extracted = extractEncryptionSchemaV3(users) + const extracted = extractEncryptionSchema(users) const authored = encryptedTable('users', { email: v3Types.TextSearch('email'), age: v3Types.IntegerOrd('age'), @@ -51,8 +51,8 @@ describe('extractEncryptionSchemaV3', () => { email: types.IntegerOrd('email'), }) - const accountsSchema = extractEncryptionSchemaV3(accounts) - const metricsSchema = extractEncryptionSchemaV3(metrics) + const accountsSchema = extractEncryptionSchema(accounts) + const metricsSchema = extractEncryptionSchema(metrics) expect(accountsSchema.build()).toStrictEqual( encryptedTable('accounts', { @@ -72,7 +72,7 @@ describe('extractEncryptionSchemaV3', () => { emailAddress: types.TextEq('email_address'), }) - expect(extractEncryptionSchemaV3(users).build()).toStrictEqual( + expect(extractEncryptionSchema(users).build()).toStrictEqual( encryptedTable('users', { createdOn: v3Types.Date('created_on'), emailAddress: v3Types.TextEq('email_address'), @@ -89,7 +89,7 @@ describe('extractEncryptionSchemaV3', () => { }, } - expect(extractEncryptionSchemaV3(table as never).build()).toStrictEqual( + expect(extractEncryptionSchema(table as never).build()).toStrictEqual( encryptedTable('users', { createdOn: v3Types.Date('created_on'), }).build(), @@ -98,12 +98,12 @@ describe('extractEncryptionSchemaV3', () => { it('throws when the table has no encrypted v3 columns', () => { const plain = pgTable('plain', { id: integer() }) - expect(() => extractEncryptionSchemaV3(plain)).toThrow(/no encrypted v3/i) + expect(() => extractEncryptionSchema(plain)).toThrow(/no encrypted v3/i) }) it('throws the table-name error before checking for encrypted columns', () => { expect(() => - extractEncryptionSchemaV3({ + extractEncryptionSchema({ secret: { getSQLType: () => 'public.eql_v3_text_eq', name: 'secret' }, } as never), ).toThrow('Unable to read table name from Drizzle table.') diff --git a/packages/stack-drizzle/__tests__/v3/selector.test.ts b/packages/stack-drizzle/__tests__/selector.test.ts similarity index 96% rename from packages/stack-drizzle/__tests__/v3/selector.test.ts rename to packages/stack-drizzle/__tests__/selector.test.ts index e3c2296cf..cf2fec265 100644 --- a/packages/stack-drizzle/__tests__/v3/selector.test.ts +++ b/packages/stack-drizzle/__tests__/selector.test.ts @@ -1,12 +1,12 @@ import { integer, pgTable } from 'drizzle-orm/pg-core' import { describe, expect, it } from 'vitest' import { - createEncryptionOperatorsV3, + createEncryptionOperators, EncryptionOperatorError, parseSelectorSegments, reconstructSelectorDocument, -} from '../../src/v3/operators.js' -import { types } from '../../src/v3/types.js' +} from '../src/operators.js' +import { types } from '../src/types.js' describe('parseSelectorSegments', () => { it('parses $-rooted, bare, and whitespace-padded dot paths', () => { @@ -70,7 +70,7 @@ describe('ops.selector — up-front guards (no encryption reached)', () => { const failIfCalled = () => { throw new Error('guard should reject before encrypting') } - const ops = createEncryptionOperatorsV3({ + const ops = createEncryptionOperators({ encryptQuery: failIfCalled, encrypt: failIfCalled, } as never) diff --git a/packages/stack-drizzle/__tests__/v3/sql-dialect.test.ts b/packages/stack-drizzle/__tests__/sql-dialect.test.ts similarity index 98% rename from packages/stack-drizzle/__tests__/v3/sql-dialect.test.ts rename to packages/stack-drizzle/__tests__/sql-dialect.test.ts index 42feef81a..bd67750b3 100644 --- a/packages/stack-drizzle/__tests__/v3/sql-dialect.test.ts +++ b/packages/stack-drizzle/__tests__/sql-dialect.test.ts @@ -1,7 +1,7 @@ import { not, sql } from 'drizzle-orm' import { PgDialect } from 'drizzle-orm/pg-core' import { describe, expect, it } from 'vitest' -import { EQL_V3_FN_SCHEMA, v3Dialect } from '../../src/v3/sql-dialect' +import { EQL_V3_FN_SCHEMA, v3Dialect } from '../src/sql-dialect' const dialect = new PgDialect() const render = (s: ReturnType) => dialect.sqlToQuery(s).sql diff --git a/packages/stack-drizzle/__tests__/v3/types.test-d.ts b/packages/stack-drizzle/__tests__/types.test-d.ts similarity index 97% rename from packages/stack-drizzle/__tests__/v3/types.test-d.ts rename to packages/stack-drizzle/__tests__/types.test-d.ts index 55b8022c2..18f81ae88 100644 --- a/packages/stack-drizzle/__tests__/v3/types.test-d.ts +++ b/packages/stack-drizzle/__tests__/types.test-d.ts @@ -1,7 +1,7 @@ import type { types as v3Types } from '@cipherstash/stack/eql/v3' import type { Encrypted } from '@cipherstash/stack/types' import { describe, expectTypeOf, it } from 'vitest' -import { types } from '../../src/v3/types' +import { types } from '../src/types' describe('v3 drizzle types - type-level', () => { it('exposes exactly the same factory keys as @/eql/v3 types', () => { diff --git a/packages/stack-drizzle/__tests__/v3/types.test.ts b/packages/stack-drizzle/__tests__/types.test.ts similarity index 87% rename from packages/stack-drizzle/__tests__/v3/types.test.ts rename to packages/stack-drizzle/__tests__/types.test.ts index face747e1..b82251a9a 100644 --- a/packages/stack-drizzle/__tests__/v3/types.test.ts +++ b/packages/stack-drizzle/__tests__/types.test.ts @@ -1,7 +1,7 @@ import { types as v3Types } from '@cipherstash/stack/eql/v3' import { describe, expect, it } from 'vitest' -import { getEqlV3Column } from '../../src/v3/column' -import { types } from '../../src/v3/types' +import { getEqlV3Column } from '../src/column' +import { types } from '../src/types' describe('v3 drizzle types namespace', () => { it('exposes the same factory names as @/eql/v3 types', () => { diff --git a/packages/stack-drizzle/integration/adapter.ts b/packages/stack-drizzle/integration/adapter.ts index 524ad33d1..635572de3 100644 --- a/packages/stack-drizzle/integration/adapter.ts +++ b/packages/stack-drizzle/integration/adapter.ts @@ -1,4 +1,8 @@ -import { EncryptionV3 } from '@cipherstash/stack/v3' +import { + type AnyV3Table, + type EncryptionClientFor, + EncryptionV3, +} from '@cipherstash/stack/v3' import { databaseUrl, type IntegrationAdapter, @@ -11,11 +15,11 @@ import { asc, type SQL } from 'drizzle-orm' import { type PgTable, pgTable, text } from 'drizzle-orm/pg-core' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' -import { makeEqlV3Column } from '../src/v3/column' +import { makeEqlV3Column } from '../src/column' import { - createEncryptionOperatorsV3, - extractEncryptionSchemaV3, -} from '../src/v3/index.js' + createEncryptionOperators, + extractEncryptionSchema, +} from '../src/index.js' /** * The Drizzle v3 adapter under real ZeroKMS ciphertext. @@ -57,10 +61,10 @@ type AnyTable = any export function makeDrizzleAdapter(): IntegrationAdapter { let sqlClient: postgres.Sql let db: ReturnType - let client: Awaited> - let ops: ReturnType + let client: EncryptionClientFor + let ops: ReturnType let table: AnyTable - let schema: ReturnType + let schema: ReturnType let tableName: string const column = (slug: string) => table[slug] @@ -140,7 +144,7 @@ export function makeDrizzleAdapter(): IntegrationAdapter { rowKey: text('row_key').primaryKey(), ...columns, }) - schema = extractEncryptionSchemaV3(table) + schema = extractEncryptionSchema(table) // The DDL comes from the column builders, not from a hand-written list, so // a domain rename cannot silently desync the table from the schema. @@ -158,7 +162,7 @@ export function makeDrizzleAdapter(): IntegrationAdapter { // The client must know this table's schema to encrypt for it. Rebuilt per // family, since each family has its own columns. client = await EncryptionV3({ schemas: [schema as never] }) - ops = createEncryptionOperatorsV3(client) + ops = createEncryptionOperators(client) }, async insertSingle(_spec: TableSpec, row: PlainRow) { diff --git a/packages/stack-drizzle/integration/json-adapter.ts b/packages/stack-drizzle/integration/json-adapter.ts index 181b98fb5..44743a1d3 100644 --- a/packages/stack-drizzle/integration/json-adapter.ts +++ b/packages/stack-drizzle/integration/json-adapter.ts @@ -1,4 +1,8 @@ -import { EncryptionV3 } from '@cipherstash/stack/v3' +import { + type AnyV3Table, + type EncryptionClientFor, + EncryptionV3, +} from '@cipherstash/stack/v3' import { databaseUrl, type JsonIntegrationAdapter, @@ -11,10 +15,10 @@ import { type PgTable, pgTable, text } from 'drizzle-orm/pg-core' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' import { - createEncryptionOperatorsV3, - extractEncryptionSchemaV3, + createEncryptionOperators, + extractEncryptionSchema, types, -} from '../src/v3/index.js' +} from '../src/index.js' // biome-ignore lint/suspicious/noExplicitAny: the table name is supplied by the shared suite at runtime type AnyTable = any @@ -25,8 +29,8 @@ export function makeDrizzleJsonAdapter(): JsonIntegrationAdapter { let db: ReturnType let tableName: string let table: AnyTable - let client: Awaited> - let ops: ReturnType + let client: EncryptionClientFor + let ops: ReturnType const rowsFor = async ( where: SQL | undefined, @@ -71,9 +75,9 @@ export function makeDrizzleJsonAdapter(): JsonIntegrationAdapter { rowKey: text('row_key').primaryKey(), document: types.Json('document'), }) - const schema = extractEncryptionSchemaV3(table) + const schema = extractEncryptionSchema(table) client = await EncryptionV3({ schemas: [schema] }) - ops = createEncryptionOperatorsV3(client) + ops = createEncryptionOperators(client) await sqlClient.unsafe(`DROP TABLE IF EXISTS ${tableName}`) await sqlClient.unsafe(` diff --git a/packages/stack-drizzle/integration/lock-context.integration.test.ts b/packages/stack-drizzle/integration/lock-context.integration.test.ts index 293c34853..489a78100 100644 --- a/packages/stack-drizzle/integration/lock-context.integration.test.ts +++ b/packages/stack-drizzle/integration/lock-context.integration.test.ts @@ -33,7 +33,11 @@ * control. */ import { OidcFederationStrategy } from '@cipherstash/stack' -import { EncryptionV3 } from '@cipherstash/stack/v3' +import { + type AnyV3Table, + type EncryptionClientFor, + EncryptionV3, +} from '@cipherstash/stack/v3' import { databaseUrl, unwrapResult, V3_MATRIX } from '@cipherstash/test-kit' import { clerkJwtProvider } from '@cipherstash/test-kit/integration-clerk' import { and, asc as drizzleAsc, eq as drizzleEq, type SQL } from 'drizzle-orm' @@ -41,11 +45,11 @@ import { integer, pgTable, text } from 'drizzle-orm/pg-core' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { makeEqlV3Column } from '../src/v3/column' +import { makeEqlV3Column } from '../src/column' import { - createEncryptionOperatorsV3, - extractEncryptionSchemaV3, -} from '../src/v3/index.js' + createEncryptionOperators, + extractEncryptionSchema, +} from '../src/index.js' const sqlClient = postgres(databaseUrl(), { prepare: false }) @@ -67,12 +71,12 @@ const secretTable = pgTable(TABLE_NAME, { secret: makeEqlV3Column(V3_MATRIX['public.eql_v3_text_eq'].builder('secret')), } as never) -const schema = extractEncryptionSchemaV3(secretTable) +const schema = extractEncryptionSchema(secretTable) type SelectRow = { rowKey: string } -let client: Awaited> -let ops: ReturnType +let client: EncryptionClientFor +let ops: ReturnType let db: ReturnType /** @@ -159,7 +163,7 @@ beforeAll(async () => { schemas: [schema], config: { authStrategy: federation.data }, }) - ops = createEncryptionOperatorsV3(client) + ops = createEncryptionOperators(client) db = drizzle({ client: sqlClient }) await sqlClient.unsafe(` diff --git a/packages/stack-drizzle/integration/null-persistence.integration.test.ts b/packages/stack-drizzle/integration/null-persistence.integration.test.ts index 6534c60ac..6a0e0f418 100644 --- a/packages/stack-drizzle/integration/null-persistence.integration.test.ts +++ b/packages/stack-drizzle/integration/null-persistence.integration.test.ts @@ -13,18 +13,22 @@ * as SQL NULL, and the present cell still decrypts to its plaintext. */ -import { EncryptionV3 } from '@cipherstash/stack/v3' +import { + type AnyV3Table, + type EncryptionClientFor, + EncryptionV3, +} from '@cipherstash/stack/v3' import { databaseUrl, V3_MATRIX } from '@cipherstash/test-kit' import { and, asc as drizzleAsc, eq as drizzleEq, type SQL } from 'drizzle-orm' import { integer, pgTable, text } from 'drizzle-orm/pg-core' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { makeEqlV3Column } from '../src/v3/column' +import { makeEqlV3Column } from '../src/column' import { - createEncryptionOperatorsV3, - extractEncryptionSchemaV3, -} from '../src/v3/index.js' + createEncryptionOperators, + extractEncryptionSchema, +} from '../src/index.js' const sqlClient = postgres(databaseUrl(), { prepare: false }) @@ -80,12 +84,12 @@ const TIERS = [ }, ] as const -const schema = extractEncryptionSchemaV3(nullableTable) +const schema = extractEncryptionSchema(nullableTable) type SelectRow = { rowKey: string } -let client: Awaited> -let ops: ReturnType +let client: EncryptionClientFor +let ops: ReturnType let db: ReturnType function unwrap(result: { data?: T; failure?: { message: string } }): T { @@ -108,7 +112,7 @@ async function selectRowKeys(condition: SQL): Promise { beforeAll(async () => { // EQL v3 is installed once per run by `global-setup.ts`. client = await EncryptionV3({ schemas: [schema] }) - ops = createEncryptionOperatorsV3(client) + ops = createEncryptionOperators(client) db = drizzle({ client: sqlClient }) const columnDefs = TIERS.map((t) => `"${t.db}" ${t.domain}`).join(',\n ') diff --git a/packages/stack-drizzle/integration/relational.integration.test.ts b/packages/stack-drizzle/integration/relational.integration.test.ts index ee36fcf7f..5fac50151 100644 --- a/packages/stack-drizzle/integration/relational.integration.test.ts +++ b/packages/stack-drizzle/integration/relational.integration.test.ts @@ -19,7 +19,11 @@ * `stash eql install`. This suite throws rather than skips when unconfigured. */ -import { EncryptionV3 } from '@cipherstash/stack/v3' +import { + type AnyV3Table, + type EncryptionClientFor, + EncryptionV3, +} from '@cipherstash/stack/v3' import { type DomainSpec, databaseUrl, @@ -43,13 +47,13 @@ import { integer, pgTable, text } from 'drizzle-orm/pg-core' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { makeEqlV3Column } from '../src/v3/column' +import { makeEqlV3Column } from '../src/column' import { - createEncryptionOperatorsV3, + createEncryptionOperators, EncryptionOperatorError, - extractEncryptionSchemaV3, + extractEncryptionSchema, types as v3drizzle, -} from '../src/v3/index.js' +} from '../src/index.js' const sqlClient = postgres(databaseUrl(), { prepare: false }) @@ -128,16 +132,16 @@ const BIGINT_LEDGER = -9223372036854775808n const BIGINT_B_BALANCE = -5n const BIGINT_B_LEDGER = 100n -const schema = extractEncryptionSchemaV3(matrixTable) -const bigintSchema = extractEncryptionSchemaV3(bigintTable) +const schema = extractEncryptionSchema(matrixTable) +const bigintSchema = extractEncryptionSchema(bigintTable) type PlainValue = string | number | bigint | boolean | Date type RowKey = (typeof ROWS)[number] type MatrixPlainRow = Record type SelectRow = { rowKey: string } type Db = ReturnType -type Client = Awaited> -type Ops = ReturnType +type Client = EncryptionClientFor +type Ops = ReturnType type ComparisonOperator = 'gt' | 'gte' | 'lt' | 'lte' let client: Client @@ -213,7 +217,7 @@ function encryptedInsertRows(): MatrixPlainRow[] { beforeAll(async () => { client = await EncryptionV3({ schemas: [schema, bigintSchema] }) - ops = createEncryptionOperatorsV3(client) + ops = createEncryptionOperators(client) db = drizzle({ client: sqlClient }) const columnDefs = matrixEntries diff --git a/packages/stack-drizzle/package.json b/packages/stack-drizzle/package.json index dd004ad62..48320122c 100644 --- a/packages/stack-drizzle/package.json +++ b/packages/stack-drizzle/package.json @@ -43,25 +43,8 @@ "default": "./dist/index.cjs" } }, - "./v3": { - "import": { - "types": "./dist/v3/index.d.ts", - "default": "./dist/v3/index.js" - }, - "require": { - "types": "./dist/v3/index.d.cts", - "default": "./dist/v3/index.cjs" - } - }, "./package.json": "./package.json" }, - "typesVersions": { - "*": { - "v3": [ - "./dist/v3/index.d.ts" - ] - } - }, "scripts": { "prebuild": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", "build": "tsup", diff --git a/packages/stack-drizzle/src/v3/codec.ts b/packages/stack-drizzle/src/codec.ts similarity index 100% rename from packages/stack-drizzle/src/v3/codec.ts rename to packages/stack-drizzle/src/codec.ts diff --git a/packages/stack-drizzle/src/v3/column.ts b/packages/stack-drizzle/src/column.ts similarity index 100% rename from packages/stack-drizzle/src/v3/column.ts rename to packages/stack-drizzle/src/column.ts diff --git a/packages/stack-drizzle/src/index.ts b/packages/stack-drizzle/src/index.ts index ff3f12bb0..07afb0e90 100644 --- a/packages/stack-drizzle/src/index.ts +++ b/packages/stack-drizzle/src/index.ts @@ -1,234 +1,9 @@ -import type { - CastAs, - MatchIndexOpts, - TokenFilter, -} from '@cipherstash/stack/schema' -import { customType } from 'drizzle-orm/pg-core' - -export type { CastAs, MatchIndexOpts, TokenFilter } - -// The encrypted column type is created by the EQL install script in the -// `public` schema (see packages/cli/src/installer/index.ts). We return the -// bare identifier here — drizzle-kit's CREATE TABLE path emits it correctly -// (it only prepends a schema when `typeSchema` is set, which is only true -// for pgEnum columns). The ALTER COLUMN path is a different story: it -// unconditionally wraps the dataType() return in double-quotes and prepends -// `"{typeSchema}".`, and since custom types have no typeSchema, the output -// becomes `"undefined"."eql_v2_encrypted"`. Returning a pre-quoted -// `"public"."eql_v2_encrypted"` here does NOT fix that — drizzle-kit just -// double-escapes the quotes, producing `"undefined".""public"."eql_v2_encrypted""`. -// Instead, the CLI's `rewriteEncryptedAlterColumns` rewrites every broken -// ALTER COLUMN form into an ADD + DROP + RENAME sequence that does work. -const EQL_ENCRYPTED_DATA_TYPE = 'eql_v2_encrypted' - -/** - * Configuration for encrypted column indexes and data types - */ -export type EncryptedColumnConfig = { - /** - * Data type for the column (default: 'string') - */ - dataType?: CastAs - /** - * Enable free text search. Can be a boolean for default options, or an object for custom configuration. - */ - freeTextSearch?: boolean | MatchIndexOpts - /** - * Enable equality index. Can be a boolean for default options, or an array of token filters. - */ - equality?: boolean | TokenFilter[] - /** - * Enable order and range index for sorting and range queries. - */ - orderAndRange?: boolean - /** - * Enable searchable JSON index for JSONB path queries. - * Requires dataType: 'json'. - */ - searchableJson?: boolean -} - -/** - * Map to store configuration for encrypted columns - * Keyed by column name (the name passed to encryptedType) - */ -const columnConfigMap = new Map< - string, - EncryptedColumnConfig & { name: string } ->() - -/** - * Creates an encrypted column type for Drizzle ORM with configurable searchable encryption options. - * - * When data is encrypted, the actual stored value is an [EQL v2](/docs/reference/eql) encrypted composite type which includes any searchable encryption indexes defined for the column. - * Importantly, the original data type is not known until it is decrypted. Therefore, this function allows specifying - * the original data type via the `dataType` option in the configuration. - * This ensures that when data is decrypted, it can be correctly interpreted as the intended TypeScript type. - * - * @typeParam TData - The TypeScript type of the data stored in the column - * @param name - The column name in the database - * @param config - Optional configuration for data type and searchable encryption indexes - * @returns A Drizzle column type that can be used in pgTable definitions - * - * ## Searchable Encryption Options - * - * - `dataType`: Specifies the original data type of the column (e.g., 'string', 'number', 'json'). Default is 'string'. - * - `freeTextSearch`: Enables free text search index. Can be a boolean for default options, or an object for custom configuration. - * - `equality`: Enables equality index. Can be a boolean for default options, or an array of token filters. - * - `orderAndRange`: Enables order and range index for sorting and range queries. - * - `searchableJson`: Enables searchable JSON index for JSONB path queries on encrypted JSON columns. - * - * See {@link EncryptedColumnConfig}. - * - * @example - * Defining a drizzle table schema for postgres table with encrypted columns. - * - * ```typescript - * import { pgTable, integer, timestamp } from 'drizzle-orm/pg-core' - * import { encryptedType } from '@cipherstash/stack-drizzle' - * - * const users = pgTable('users', { - * email: encryptedType('email', { - * freeTextSearch: true, - * equality: true, - * orderAndRange: true, - * }), - * age: encryptedType('age', { - * dataType: 'number', - * equality: true, - * orderAndRange: true, - * }), - * profile: encryptedType('profile', { - * dataType: 'json', - * }), - * }) - * ``` - */ -export const encryptedType = ( - name: string, - config?: EncryptedColumnConfig, -) => { - // Create the Drizzle custom type - const customColumnType = customType<{ data: TData; driverData: string }>({ - dataType() { - return EQL_ENCRYPTED_DATA_TYPE - }, - toDriver(value: TData): string { - const jsonStr = JSON.stringify(value) - const escaped = jsonStr.replace(/"/g, '""') - return `("${escaped}")` - }, - fromDriver(value: string): TData { - const parseComposite = (str: string) => { - if (!str || str === '') return null - - const trimmed = str.trim() - - if (trimmed.startsWith('(') && trimmed.endsWith(')')) { - let inner = trimmed.slice(1, -1) - inner = inner.replace(/""/g, '"') - - if (inner.startsWith('"') && inner.endsWith('"')) { - const stripped = inner.slice(1, -1) - return JSON.parse(stripped) - } - - if (inner.startsWith('{') || inner.startsWith('[')) { - return JSON.parse(inner) - } - - return inner - } - - return JSON.parse(str) - } - - return parseComposite(value) as TData - }, - }) - - // Create the column instance - const column = customColumnType(name) - - // Store configuration keyed by column name - // This allows us to look it up during schema extraction - const fullConfig: EncryptedColumnConfig & { name: string } = { - name, - ...config, - } - - // Store in Map keyed by column name (will be looked up during extraction) - columnConfigMap.set(name, fullConfig) - - // Also store on property for immediate access (before pgTable processes it) - // We need to use any here because Drizzle columns don't have a type for custom properties - // biome-ignore lint/suspicious/noExplicitAny: Drizzle columns don't expose custom property types - ;(column as any)._encryptionConfig = fullConfig - - return column -} - -/** - * Get configuration for an encrypted column by checking if it's an encrypted type - * and looking up the config by column name - * @internal - */ -export function getEncryptedColumnConfig( - columnName: string, - column: unknown, -): (EncryptedColumnConfig & { name: string }) | undefined { - // Check if this is an encrypted column - if (column && typeof column === 'object') { - // We need to use any here to access Drizzle column properties - // biome-ignore lint/suspicious/noExplicitAny: Drizzle column types don't expose all properties - const columnAny = column as any - - // Check if it's an encrypted column by checking sqlName or dataType. - // We accept both the bare `eql_v2_encrypted` form (current) and the - // fully-qualified `"public"."eql_v2_encrypted"` form that @cipherstash/stack - // 0.15.0 briefly emitted, for back-compat with tables built against that - // release. - const isEncryptedTypeString = (value: unknown): boolean => - value === EQL_ENCRYPTED_DATA_TYPE || - value === '"public"."eql_v2_encrypted"' - - const isEncrypted = - isEncryptedTypeString(columnAny.sqlName) || - isEncryptedTypeString(columnAny.dataType) || - (columnAny.dataType && - typeof columnAny.dataType === 'function' && - isEncryptedTypeString(columnAny.dataType())) - - if (isEncrypted) { - // Try to get config from property (if still there) - if (columnAny._encryptionConfig) { - return columnAny._encryptionConfig - } - - // Look up config by column name (the name passed to encryptedType) - // The column.name should match what was passed to encryptedType - const lookupName = columnAny.name || columnName - return columnConfigMap.get(lookupName) - } - } - return undefined -} - -/** - * Create Drizzle query operators (`eq`, `lt`, `gt`, etc.) that work with - * encrypted columns. The returned operators encrypt query values before - * passing them to Drizzle, enabling searchable encryption in standard - * Drizzle queries. - */ +export { EqlV3CodecError, v3FromDriver, v3ToDriver } from './codec.js' +export { getEqlV3Column, isEqlV3Column, makeEqlV3Column } from './column.js' +export { encryptedIndexes } from './indexes.js' export { createEncryptionOperators, - EncryptionConfigError, EncryptionOperatorError, } from './operators.js' -/** - * Extract a CipherStash encryption schema from a Drizzle table definition. - * - * Inspects columns created with {@link encryptedType} and builds the equivalent - * `encryptedTable` / `encryptedColumn` schema automatically. - */ export { extractEncryptionSchema } from './schema-extraction.js' +export { types } from './types.js' diff --git a/packages/stack-drizzle/src/v3/indexes.ts b/packages/stack-drizzle/src/indexes.ts similarity index 99% rename from packages/stack-drizzle/src/v3/indexes.ts rename to packages/stack-drizzle/src/indexes.ts index 0649a090a..d43afa58f 100644 --- a/packages/stack-drizzle/src/v3/indexes.ts +++ b/packages/stack-drizzle/src/indexes.ts @@ -14,7 +14,7 @@ import { EQL_V3_FN_SCHEMA } from './sql-dialect.js' * * ```ts * import { integer, pgTable } from 'drizzle-orm/pg-core' - * import { encryptedIndexes, types } from '@cipherstash/stack-drizzle/v3' + * import { encryptedIndexes, types } from '@cipherstash/stack-drizzle' * * export const users = pgTable( * 'users', diff --git a/packages/stack-drizzle/src/operators.ts b/packages/stack-drizzle/src/operators.ts index 674bb3ba3..4f6df813d 100644 --- a/packages/stack-drizzle/src/operators.ts +++ b/packages/stack-drizzle/src/operators.ts @@ -1,90 +1,96 @@ -import type { EncryptionClient } from '@cipherstash/stack/encryption' +import type { Result } from '@byteslice/result' +import type { AuditConfig } from '@cipherstash/stack/adapter-kit' +import { + jsonPathOf, + matchNeedleError, + parseSelectorSegments, + reconstructSelectorDocument, + stripDomainSchema, + unsupportedLeafReason, +} from '@cipherstash/stack/adapter-kit' +import type { + AnyEncryptedV3Column, + AnyV3Table, +} from '@cipherstash/stack/eql/v3' +import type { EncryptionError } from '@cipherstash/stack/errors' +import type { LockContext } from '@cipherstash/stack/identity' +import type { ColumnSchema } from '@cipherstash/stack/schema' import type { - EncryptedColumn, - EncryptedTable, - EncryptedTableColumn, -} from '@cipherstash/stack/schema' -import { type QueryTypeName, queryTypes } from '@cipherstash/stack/types' + EncryptedQueryResult, + QueryTypeName, +} from '@cipherstash/stack/types' import { and, - arrayContained, - arrayContains, - arrayOverlaps, asc, - between, - bindIfParam, + Column, desc, - eq, exists, - gt, - gte, - ilike, - inArray, + is, isNotNull, isNull, - like, - lt, - lte, - ne, not, - notBetween, notExists, - notIlike, - notInArray, or, type SQL, type SQLWrapper, sql, } from 'drizzle-orm' import type { PgTable } from 'drizzle-orm/pg-core' -import type { EncryptedColumnConfig } from './index.js' -import { getEncryptedColumnConfig } from './index.js' -import { extractEncryptionSchema } from './schema-extraction.js' - -// ============================================================================ -// Type Definitions and Type Guards -// ============================================================================ - -/** - * Branded type for Drizzle table with encrypted columns - */ -// biome-ignore lint/suspicious/noExplicitAny: Drizzle table types don't expose Symbol properties -type EncryptedDrizzleTable = PgTable & { - readonly __isEncryptedTable?: true -} +import { getEqlV3Column } from './column.js' +import { + extractEncryptionSchema, + getDrizzleTableName, +} from './schema-extraction.js' +import { type ComparisonOp, type EqualityOp, v3Dialect } from './sql-dialect.js' /** - * Type guard to check if a value is a Drizzle SQLWrapper + * The client capability this factory consumes: `encryptQuery`, in both its + * single (`value, opts`) and batch (`terms[]`) forms. Declared structurally — + * with maximally-permissive operands — so it is satisfied by the nominal + * `EncryptionClient`, by the `TypedEncryptionClient` that `EncryptionV3` returns + * (whatever its schema tuple), AND by a hand-rolled test double, none needing a + * cast. Typing the parameter to the nominal `TypedEncryptionClient` would + * reject a client built for a narrower schema tuple (it accepts fewer tables than + * `readonly AnyV3Table[]`); the structural surface sidesteps that variance. + * + * Every operand is a QUERY TERM, not a storage envelope: `encryptQuery` mints a + * ciphertext-free term (no `c`) carrying all of the column's configured index + * terms, which the operator layer casts to the column's `eql_v3.query_` + * type. This reaches the bundle's `(domain, query_)` overloads and keeps + * WHERE-clause payloads free of the ciphertext a query never needs (#622). + * + * `never` operands: the real client's `encryptQuery` is generic (`queryType` is + * constrained to the column's own query types), which a concrete signature here + * cannot match. `never` params keep the structural surface satisfiable by that + * generic method AND by a test double; the call sites cast their real operands. */ -function isSQLWrapper(value: unknown): value is SQLWrapper { - return ( - typeof value === 'object' && - value !== null && - 'sql' in value && - typeof (value as { sql: unknown }).sql !== 'undefined' - ) +type OperandEncryptionClient = { + encryptQuery( + value: never, + opts: never, + ): ChainableOperation + encryptQuery(terms: never): ChainableOperation } -/** - * Type guard to check if a value is a Drizzle table - */ -function isPgTable(value: unknown): value is EncryptedDrizzleTable { - return ( - typeof value === 'object' && - value !== null && - Symbol.for('drizzle:Name') in value - ) -} +// Path helpers now live in @cipherstash/stack/adapter-kit (shared with the +// Supabase adapter, #650); re-exported so existing imports keep working. +export { parseSelectorSegments, reconstructSelectorDocument } /** - * Custom error types for better debugging + * A dedicated error for v3 operator gating and operand-encryption failures, + * carrying the offending column/table/operator for diagnostics. + * + * INTENTIONAL FORK: this mirrors the v2 adapter's `EncryptionOperatorError` + * rather than sharing it. Unifying the two would couple `./drizzle` and + * `./eql/v3/drizzle` — two independently-versioned public entry points — so the + * duplication is deliberate, not an oversight. */ export class EncryptionOperatorError extends Error { constructor( message: string, public readonly context?: { - tableName?: string columnName?: string + tableName?: string operator?: string }, ) { @@ -93,1853 +99,861 @@ export class EncryptionOperatorError extends Error { } } -export class EncryptionConfigError extends EncryptionOperatorError { - constructor(message: string, context?: EncryptionOperatorError['context']) { - super(message, context) - this.name = 'EncryptionConfigError' - } -} - -// ============================================================================ -// Utility Functions -// ============================================================================ - -/** - * Helper to extract table name from a Drizzle table - */ -function getDrizzleTableName(drizzleTable: unknown): string | undefined { - if (!isPgTable(drizzleTable)) { - return undefined - } - // Access Symbol property using Record type to avoid indexing errors - const tableWithSymbol = drizzleTable as unknown as Record< - symbol, - string | undefined - > - return tableWithSymbol[Symbol.for('drizzle:Name')] -} - -/** - * Helper to get the drizzle table from a drizzle column - */ -function getDrizzleTableFromColumn(drizzleColumn: SQLWrapper): unknown { - const column = drizzleColumn as unknown as Record - return column.table as unknown -} - -/** - * Helper to extract encrypted table from a drizzle column by deriving it from the column's parent table - */ -function getEncryptedTableFromColumn( - drizzleColumn: SQLWrapper, - tableCache: Map>, -): EncryptedTable | undefined { - const drizzleTable = getDrizzleTableFromColumn(drizzleColumn) - if (!drizzleTable) { - return undefined - } - - const tableName = getDrizzleTableName(drizzleTable) - if (!tableName) { - return undefined - } - - // Check cache first - let encryptedTable = tableCache.get(tableName) - if (encryptedTable) { - return encryptedTable - } - - // Extract encryption schema from drizzle table and cache it - try { - // biome-ignore lint/suspicious/noExplicitAny: PgTable type doesn't expose all needed properties - encryptedTable = extractEncryptionSchema(drizzleTable as PgTable) - tableCache.set(tableName, encryptedTable) - return encryptedTable - } catch { - // Table doesn't have encrypted columns or extraction failed - return undefined - } -} - -/** - * Helper to get the encrypted column definition for a Drizzle column from the encrypted table - */ -function getEncryptedColumn( - drizzleColumn: SQLWrapper, - encryptedTable: EncryptedTable, -): EncryptedColumn | undefined { - const column = drizzleColumn as unknown as Record - const columnName = column.name as string | undefined - if (!columnName) { - return undefined - } - - const tableRecord = encryptedTable as unknown as Record - return tableRecord[columnName] as EncryptedColumn | undefined +interface ColumnContext { + builder: AnyEncryptedV3Column + table: AnyV3Table + indexes: ColumnSchema['indexes'] + columnName: string + tableName: string + /** The `eql_v3.query_` type an operand for this column casts to, so + * `encryptQuery`'s ciphertext-free term reaches the narrowed-query overloads. + * `null` for storage-only columns (no query domain); those never encrypt an + * operand — every operator gates on a query capability first. JSON columns + * override this at the call site (`query_json`, an irregular name). */ + queryCast: string | null } /** - * Column metadata extracted from a Drizzle column + * The `eql_v3.query_` cast for a column's storage domain — e.g. + * `public.eql_v3_text_search` → `eql_v3.query_text_search`. Uniform across the + * queryable column domains (`_eq`, `_ord`, `_ord_ore`, `_match`, `_search`); the + * two irregular cases are handled elsewhere: storage-only domains + * (`eql_v3_boolean`, the bare base types) have no query domain and return `null` + * (they are never queried), and `eql_v3_json_search` maps to `query_json`, cast + * explicitly on the JSON path. */ -interface ColumnInfo { - readonly encryptedColumn: EncryptedColumn | undefined - readonly config: (EncryptedColumnConfig & { name: string }) | undefined - readonly encryptedTable: EncryptedTable | undefined - readonly columnName: string - readonly tableName: string | undefined +function queryCastForDomain(eqlType: string): string | null { + const bare = stripDomainSchema(eqlType) // public.eql_v3_text_search → eql_v3_text_search + const prefix = 'eql_v3_' + if (!bare.startsWith(prefix)) return null + const suffix = bare.slice(prefix.length) + // No index suffix (bare storage-only domain like `boolean`, `text`) → no query + // domain exists. These are gated out before any operand is encrypted. The + // suffixes match the column factories in `@/eql/v3/columns` exactly: ope + // ordering is the `_ord` domain (not `_ord_ope`) and text search is `_search` + // (there is no `_search_ore` column), so those two never occur here. + if (!/_(eq|ord|ord_ore|match|search)$/.test(suffix)) { + return null + } + return `eql_v3.query_${suffix}` } -/** - * Helper to get the encrypted column and column config for a Drizzle column - * If encryptedTable is not provided, it will be derived from the column - */ -function getColumnInfo( - drizzleColumn: SQLWrapper, - encryptedTable: EncryptedTable | undefined, - tableCache: Map>, -): ColumnInfo { - const column = drizzleColumn as unknown as Record - const columnName = (column.name as string | undefined) || 'unknown' - - // If encryptedTable not provided, try to derive it from the column - let resolvedTable = encryptedTable - if (!resolvedTable) { - resolvedTable = getEncryptedTableFromColumn(drizzleColumn, tableCache) - } - - const drizzleTable = getDrizzleTableFromColumn(drizzleColumn) - const tableName = getDrizzleTableName(drizzleTable) - - if (!resolvedTable) { - // Column is not from an encrypted table - const config = getEncryptedColumnConfig(columnName, drizzleColumn) - return { - encryptedColumn: undefined, - config, - encryptedTable: undefined, - columnName, - tableName, - } - } - - const encryptedColumn = getEncryptedColumn(drizzleColumn, resolvedTable) - const config = getEncryptedColumnConfig(columnName, drizzleColumn) - - return { - encryptedColumn, - config, - encryptedTable: resolvedTable, - columnName, - tableName, - } +export type EncryptionOperatorCallOpts = { + lockContext?: LockContext + audit?: AuditConfig } /** - * Helper to convert a value to plaintext format + * An SDK encryption operation after its lock context has been applied: still + * auditable and awaitable, but not re-lockable. `withLockContext` returns this, + * not the full {@link ChainableOperation}, mirroring the real + * `EncryptOperationWithLockContext`, which drops `withLockContext` (you cannot + * lock-context twice). Modelling that is what lets the real client type satisfy + * the structural surface with no cast. */ -function toPlaintext(value: unknown): string | number | bigint { - if (typeof value === 'boolean') { - return value ? 1 : 0 - } - if ( - typeof value === 'string' || - typeof value === 'number' || - // `bigint` (int8 columns) must pass through as a native bigint, NOT via the - // `String(value)` fallthrough below — a stringified bigint would be - // encrypted as text and silently mismatch the column's bigint domain. The - // downstream `encryptQuery` term type is `Plaintext`, which carries bigint, - // and protect-ffi 0.28 i64-bounds-checks it at the boundary. - typeof value === 'bigint' - ) { - return value - } - if (value instanceof Date) { - return value.toISOString() - } - return String(value) +type AuditableOperation = { + audit(config: AuditConfig): AuditableOperation + then: PromiseLike>['then'] } /** - * Value to encrypt with its associated column + * The subset of an SDK encryption operation this factory drives: the fluent + * `withLockContext`/`audit` chain, and a `then` that resolves the operation's + * `Result`. Generic over the resolved payload `T` so the single `encryptQuery` + * carries an `EncryptedQueryResult` term and the batch form an + * `EncryptedQueryResult[]`, rather than the `unknown` this erased to before. + * + * Structural, not the concrete `EncryptQueryOperation` class, because the client + * is passed in and the factory must accept any implementation with this surface. */ -interface ValueToEncrypt { - readonly value: string | number | bigint - readonly column: SQLWrapper - readonly columnInfo: ColumnInfo - readonly queryType?: QueryTypeName - readonly originalIndex: number +type ChainableOperation = { + withLockContext(lockContext: LockContext): AuditableOperation + audit(config: AuditConfig): AuditableOperation + then: PromiseLike>['then'] } /** - * Helper to encrypt multiple values for use in a query - * Returns an array of encrypted search terms or original values if not encrypted + * Build v3-aware query operators (`eq`, `gte`, `matches`, `contains`, `asc`, …) bound to an + * encryption `client`. Each comparison/containment operator AUTO-ENCRYPTS its + * plaintext operand into an EQL v3 query term before handing it to Drizzle, so + * callers pass plaintext and the emitted SQL compares encrypted values. Every + * operator also gates on the target column's capabilities and throws + * {@link EncryptionOperatorError} when the column can't answer the operator + * (e.g. ordering a non-`ore` column). + * + * @param client - anything that can `encryptQuery` — the nominal + * `EncryptionClient` or the `TypedEncryptionClient` from `EncryptionV3` (no + * cast needed). + * @param defaults - lock context / audit applied to every operand encryption + * unless a per-call override is supplied. + * + * @example + * ```typescript + * const ops = createEncryptionOperators(await EncryptionV3({ schemas: [users] })) + * await db.select().from(users).where(await ops.eq(users.email, 'a@b.com')) + * ``` */ -async function encryptValues( - encryptionClient: EncryptionClient, - values: Array<{ - value: unknown - column: SQLWrapper - queryType?: QueryTypeName - }>, - encryptedTable: EncryptedTable | undefined, - tableCache: Map>, -): Promise { - if (values.length === 0) { - return [] - } - - // Single pass: collect values to encrypt with their metadata - const valuesToEncrypt: ValueToEncrypt[] = [] - const results: unknown[] = new Array(values.length) - - for (let i = 0; i < values.length; i++) { - const { value, column, queryType } = values[i] - const columnInfo = getColumnInfo(column, encryptedTable, tableCache) - - if ( - !columnInfo.encryptedColumn || - !columnInfo.config || - !columnInfo.encryptedTable - ) { - // Column is not encrypted, return value as-is - results[i] = value - continue - } - - const plaintextValue = toPlaintext(value) - valuesToEncrypt.push({ - value: plaintextValue, - column, - columnInfo, - queryType, - originalIndex: i, - }) - } - - if (valuesToEncrypt.length === 0) { - return results - } - - // Group values by column to batch encrypt with same column/table - const columnGroups = new Map< - string, - { - column: EncryptedColumn - table: EncryptedTable - columnName: string - values: Array<{ - value: string | number | bigint - index: number - queryType?: QueryTypeName - }> - resultIndices: number[] - } - >() - - let valueIndex = 0 - for (const { - value, - columnInfo, - queryType, - originalIndex, - } of valuesToEncrypt) { - // Safe access with validation - we know these exist from earlier checks - if ( - !columnInfo.config || - !columnInfo.encryptedColumn || - !columnInfo.encryptedTable - ) { - continue - } - - const columnName = columnInfo.config.name - const groupKey = `${columnInfo.tableName ?? 'unknown'}/${columnName}` - let group = columnGroups.get(groupKey) - if (!group) { - group = { - column: columnInfo.encryptedColumn, - table: columnInfo.encryptedTable, - columnName, - values: [], - resultIndices: [], - } - columnGroups.set(groupKey, group) - } - group.values.push({ value, index: valueIndex++, queryType }) - group.resultIndices.push(originalIndex) - } - - // Encrypt all values for each column in batches - for (const [, group] of columnGroups) { - const { columnName } = group - try { - const terms = group.values.map((v) => ({ - value: v.value, - column: group.column, - table: group.table, - queryType: v.queryType, - })) - - const encryptedTerms = await encryptionClient.encryptQuery(terms) - - if (encryptedTerms.failure) { - throw new EncryptionOperatorError( - `Failed to encrypt query terms for column "${columnName}": ${encryptedTerms.failure.message}`, - { columnName }, - ) - } - - // Map results back to original indices - for (let i = 0; i < group.values.length; i++) { - const resultIndex = group.resultIndices[i] ?? -1 - if (resultIndex >= 0 && resultIndex < results.length) { - results[resultIndex] = encryptedTerms.data[i] - } - } - } catch (error) { - if (error instanceof EncryptionOperatorError) { - throw error - } - const errorMessage = - error instanceof Error ? error.message : String(error) +export function createEncryptionOperators( + client: OperandEncryptionClient, + defaults: EncryptionOperatorCallOpts = {}, +) { + const tableCache = new WeakMap() + // Per-column context memo. `resolveContext` is value-independent, so caching + // by column identity makes `inArray`/`notInArray` build the context (and its + // deep-cloned match block) once for the whole list instead of once per value. + const contextCache = new WeakMap() + + function drizzleTableOf(column: SQLWrapper): PgTable | undefined { + return is(column, Column) + ? (column.table as PgTable | undefined) + : undefined + } + + function resolveContext(column: SQLWrapper, operator: string): ColumnContext { + const cached = contextCache.get(column) + if (cached) return cached + + const columnName = is(column, Column) ? column.name : 'unknown' + const builder = getEqlV3Column(columnName, column) + if (!builder) { throw new EncryptionOperatorError( - `Unexpected error encrypting values for column "${columnName}": ${errorMessage}`, - { columnName }, + `Operator "${operator}" requires an encrypted v3 column, but "${columnName}" is not one.`, + { columnName, operator }, ) } - } - - return results -} -/** - * Helper to encrypt a single value for use in a query - * Returns the encrypted search term or the original value if not encrypted - */ -async function encryptValue( - encryptionClient: EncryptionClient, - value: unknown, - drizzleColumn: SQLWrapper, - encryptedTable: EncryptedTable | undefined, - tableCache: Map>, - queryType?: QueryTypeName, -): Promise { - const results = await encryptValues( - encryptionClient, - [{ value, column: drizzleColumn, queryType }], - encryptedTable, - tableCache, - ) - return results[0] -} - -// ============================================================================ -// Lazy Operator Pattern -// ============================================================================ - -/** - * Simplified lazy operator that defers encryption until awaited or batched - */ -interface LazyOperator { - readonly __isLazyOperator: true - readonly operator: string - readonly queryType?: QueryTypeName - readonly left: SQLWrapper - readonly right: unknown - readonly min?: unknown - readonly max?: unknown - readonly needsEncryption: boolean - readonly columnInfo: ColumnInfo - execute( - encrypted: unknown, - encryptedMin?: unknown, - encryptedMax?: unknown, - ): SQL -} - -/** - * Type guard for lazy operators - */ -function isLazyOperator(value: unknown): value is LazyOperator { - return ( - typeof value === 'object' && - value !== null && - '__isLazyOperator' in value && - (value as LazyOperator).__isLazyOperator === true - ) -} - -/** - * Creates a lazy operator that defers execution - */ -function createLazyOperator( - operator: string, - left: SQLWrapper, - right: unknown, - execute: ( - encrypted: unknown, - encryptedMin?: unknown, - encryptedMax?: unknown, - ) => SQL, - needsEncryption: boolean, - columnInfo: ColumnInfo, - encryptionClient: EncryptionClient, - defaultTable: EncryptedTable | undefined, - tableCache: Map>, - min?: unknown, - max?: unknown, - queryType?: QueryTypeName, -): LazyOperator & Promise { - let resolvedSQL: SQL | undefined - let encryptionPromise: Promise | undefined - - const lazyOp: LazyOperator = { - __isLazyOperator: true, - operator, - queryType, - left, - right, - min, - max, - needsEncryption, - columnInfo, - execute, - } - - // Create a promise that will be resolved when encryption completes - const promise = new Promise((resolve, reject) => { - // Auto-execute when awaited directly - queueMicrotask(async () => { - if (resolvedSQL !== undefined) { - resolve(resolvedSQL) - return - } - - try { - if (!encryptionPromise) { - encryptionPromise = executeLazyOperatorDirect( - lazyOp, - encryptionClient, - defaultTable, - tableCache, - ) - } - const sql = await encryptionPromise - resolvedSQL = sql - resolve(sql) - } catch (error) { - reject(error) - } - }) - }) - - // Attach lazy operator properties to the promise - return Object.assign(promise, lazyOp) -} + const drizzleTable = drizzleTableOf(column) + const tableName = getDrizzleTableName(drizzleTable) ?? 'unknown' -/** - * Executes a lazy operator with pre-encrypted values (used in batched mode) - */ -async function executeLazyOperator( - lazyOp: LazyOperator, - encryptedValues?: { value: unknown; encrypted: unknown }[], -): Promise { - if (!lazyOp.needsEncryption) { - return lazyOp.execute(lazyOp.right) - } - - if (lazyOp.min !== undefined && lazyOp.max !== undefined) { - // Between operator - use provided encrypted values - let encryptedMin: unknown - let encryptedMax: unknown - - if (encryptedValues && encryptedValues.length >= 2) { - encryptedMin = encryptedValues[0]?.encrypted - encryptedMax = encryptedValues[1]?.encrypted - } else { - throw new EncryptionOperatorError( - 'Between operator requires both min and max encrypted values', - { - columnName: lazyOp.columnInfo.columnName, - tableName: lazyOp.columnInfo.tableName, - operator: lazyOp.operator, - }, - ) + let table = drizzleTable ? tableCache.get(drizzleTable) : undefined + if (!table && drizzleTable) { + table = extractEncryptionSchema(drizzleTable) + tableCache.set(drizzleTable, table) } - - if (encryptedMin === undefined || encryptedMax === undefined) { + if (!table) { throw new EncryptionOperatorError( - 'Between operator requires both min and max values to be encrypted', - { - columnName: lazyOp.columnInfo.columnName, - tableName: lazyOp.columnInfo.tableName, - operator: lazyOp.operator, - }, + `Unable to resolve the encrypted table for column "${columnName}".`, + { columnName, operator }, ) } - return lazyOp.execute(undefined, encryptedMin, encryptedMax) - } - - // Single value operator - let encrypted: unknown - - if (encryptedValues && encryptedValues.length > 0) { - encrypted = encryptedValues[0]?.encrypted - } else { - throw new EncryptionOperatorError( - 'Operator requires encrypted value but none provided', - { - columnName: lazyOp.columnInfo.columnName, - tableName: lazyOp.columnInfo.tableName, - operator: lazyOp.operator, - }, - ) - } - - if (encrypted === undefined) { - throw new EncryptionOperatorError( - 'Encryption failed or value was not encrypted', - { - columnName: lazyOp.columnInfo.columnName, - tableName: lazyOp.columnInfo.tableName, - operator: lazyOp.operator, - }, - ) - } - - return lazyOp.execute(encrypted) -} - -/** - * Executes a lazy operator directly by encrypting values on demand - * Used when operator is awaited directly (not batched) - */ -async function executeLazyOperatorDirect( - lazyOp: LazyOperator, - encryptionClient: EncryptionClient, - defaultTable: EncryptedTable | undefined, - tableCache: Map>, -): Promise { - if (!lazyOp.needsEncryption) { - return lazyOp.execute(lazyOp.right) - } - - if (lazyOp.min !== undefined && lazyOp.max !== undefined) { - // Between operator - encrypt min and max - const [encryptedMin, encryptedMax] = await encryptValues( - encryptionClient, - [ - { value: lazyOp.min, column: lazyOp.left, queryType: lazyOp.queryType }, - { value: lazyOp.max, column: lazyOp.left, queryType: lazyOp.queryType }, - ], - defaultTable, - tableCache, - ) - return lazyOp.execute(undefined, encryptedMin, encryptedMax) - } - - // Single value operator - const encrypted = await encryptValue( - encryptionClient, - lazyOp.right, - lazyOp.left, - defaultTable, - tableCache, - lazyOp.queryType, - ) - - return lazyOp.execute(encrypted) -} - -// ============================================================================ -// Operator Factory Functions -// ============================================================================ - -/** - * Creates a comparison operator (eq, ne, gt, gte, lt, lte) - */ -function createComparisonOperator( - operator: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte', - left: SQLWrapper, - right: unknown, - columnInfo: ColumnInfo, - encryptionClient: EncryptionClient, - defaultTable: EncryptedTable | undefined, - tableCache: Map>, -): Promise | SQL { - const { config } = columnInfo - - // Operators requiring orderAndRange index - const requiresOrderAndRange = ['gt', 'gte', 'lt', 'lte'].includes(operator) - - if (requiresOrderAndRange) { - if (!config?.orderAndRange) { - // Return regular Drizzle operator for non-encrypted columns - switch (operator) { - case 'gt': - return gt(left, right) - case 'gte': - return gte(left, right) - case 'lt': - return lt(left, right) - case 'lte': - return lte(left, right) - } - } - - // This will be replaced with encrypted value in executeLazyOperator - const executeFn = (encrypted: unknown) => { - if (encrypted === undefined) { - throw new EncryptionOperatorError( - `Encryption failed for ${operator} operator`, - { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, - operator, - }, - ) - } - return sql`eql_v2.${sql.raw(operator)}(${left}, ${bindIfParam(encrypted, left)})` - } - - return createLazyOperator( - operator, - left, - right, - executeFn, - true, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - undefined, // min - undefined, // max - queryTypes.orderAndRange, - ) as Promise - } - - // Equality operators (eq, ne) - const requiresEquality = ['eq', 'ne'].includes(operator) - - if (requiresEquality && config?.equality) { - const executeFn = (encrypted: unknown) => { - if (encrypted === undefined) { - throw new EncryptionOperatorError( - `Encryption failed for ${operator} operator`, - { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, - operator, - }, - ) - } - return operator === 'eq' ? eq(left, encrypted) : ne(left, encrypted) + const context: ColumnContext = { + builder, + table, + indexes: builder.build().indexes, + columnName, + tableName, + queryCast: queryCastForDomain(builder.getEqlType()), } - - return createLazyOperator( - operator, - left, - right, - executeFn, - true, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - undefined, // min - undefined, // max - queryTypes.equality, - ) as Promise + contextCache.set(column, context) + return context } - // Fallback to regular Drizzle operators - return operator === 'eq' ? eq(left, right) : ne(left, right) -} - -/** - * Creates a range operator (between, notBetween) - */ -function createRangeOperator( - operator: 'between' | 'notBetween', - left: SQLWrapper, - min: unknown, - max: unknown, - columnInfo: ColumnInfo, - encryptionClient: EncryptionClient, - defaultTable: EncryptedTable | undefined, - tableCache: Map>, -): Promise | SQL { - const { config } = columnInfo - - if (!config?.orderAndRange) { - return operator === 'between' - ? between(left, min, max) - : notBetween(left, min, max) - } - - const executeFn = ( - _encrypted: unknown, - encryptedMin?: unknown, - encryptedMax?: unknown, - ) => { - if (encryptedMin === undefined || encryptedMax === undefined) { + /** + * Gate an operator on the column's indexes. `indexes` is a disjunction — any + * one of them grants the capability — so equality (`unique` OR `ore`) and the + * single-index gates share one rule and one diagnostic shape. + */ + function requireIndex( + ctx: ColumnContext, + indexes: readonly ('unique' | 'ore' | 'ope' | 'match' | 'ste_vec')[], + operator: string, + capability: string, + ): void { + if (!indexes.some((index) => ctx.indexes[index])) { throw new EncryptionOperatorError( - `${operator} operator requires both min and max values`, - { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, - operator, - }, + `Operator "${operator}" requires ${capability} on column "${ctx.columnName}" (domain ${ctx.builder.getEqlType()} does not support it).`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, ) } - - const rangeCondition = sql`eql_v2.gte(${left}, ${bindIfParam(encryptedMin, left)}) AND eql_v2.lte(${left}, ${bindIfParam(encryptedMax, left)})` - - return operator === 'between' - ? rangeCondition - : sql`NOT (${rangeCondition})` } - return createLazyOperator( - operator, - left, - undefined, - executeFn, - true, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - min, - max, - queryTypes.orderAndRange, - ) as Promise -} - -/** - * Creates a text search operator (like, ilike, notIlike) - */ -function createTextSearchOperator( - operator: 'like' | 'ilike' | 'notIlike', - left: SQLWrapper, - right: unknown, - columnInfo: ColumnInfo, - encryptionClient: EncryptionClient, - defaultTable: EncryptedTable | undefined, - tableCache: Map>, -): Promise | SQL { - const { config } = columnInfo - - if (!config?.freeTextSearch) { - // Cast to satisfy TypeScript - const rightValue = right as string | SQLWrapper - switch (operator) { - case 'like': - return like(left as Parameters[0], rightValue) - case 'ilike': - return ilike(left as Parameters[0], rightValue) - case 'notIlike': - return notIlike(left as Parameters[0], rightValue) - } - } - - const executeFn = (encrypted: unknown) => { - if (encrypted === undefined) { + // Ordering flavour is pinned by the column's domain (eql-3.0.0): `_ord` + // domains carry `ope` (`op` CLLW-OPE term), `_ord_ore` domains carry `ore` + // (`ob` block-ORE term). Either satisfies the order/range operators, and an + // order-capable column answers equality via its ordering term too. + const EQUALITY_INDEXES = ['unique', 'ore', 'ope'] as const + const ORDERING_INDEXES = ['ore', 'ope'] as const + // Two DISTINCT operators, split by semantics (#617): + // - `matches` is bloom free-text (`match`, a `text_search`/`text_match` + // column): a one-sided, order- and multiplicity-insensitive token match that + // may false-positive. It emits `eql_v3.matches(col, operand)` (the SQL + // function keeps its bundle name) but is NOT containment. + // - `contains` is encrypted-JSONB containment (an `eql_v3_json_search` column, + // `ste_vec` index): exact jsonb `@>`, no false positives — genuine + // containment, so it keeps the `contains` name. + const MATCH_INDEXES = ['match'] as const + const JSON_CONTAINMENT_INDEXES = ['ste_vec'] as const + + function applyOperationOptions( + op: ChainableOperation, + opts?: EncryptionOperatorCallOpts, + ): AuditableOperation { + const lockContext = opts?.lockContext ?? defaults.lockContext + const audit = opts?.audit ?? defaults.audit + const withLock = lockContext ? op.withLockContext(lockContext) : op + if (audit) withLock.audit(audit) + return withLock + } + + function requireNonNullOperand( + ctx: ColumnContext, + value: unknown, + operator: string, + ): void { + if (value == null) { throw new EncryptionOperatorError( - `Encryption failed for ${operator} operator`, + `Operator "${operator}" cannot encrypt a null operand for column "${ctx.columnName}". Use isNull() or isNotNull() for NULL checks.`, { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, + columnName: ctx.columnName, + tableName: ctx.tableName, operator, }, ) } - - const sqlFn = sql`eql_v2.${sql.raw(operator === 'notIlike' ? 'ilike' : operator)}(${left}, ${bindIfParam(encrypted, left)})` - return operator === 'notIlike' ? sql`NOT (${sqlFn})` : sqlFn } - return createLazyOperator( - operator, - left, - right, - executeFn, - true, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - undefined, // min - undefined, // max - queryTypes.freeTextSearch, - ) as Promise -} - -/** - * Creates a JSONB operator that encrypts a JSON path selector and wraps it - * in the appropriate `eql_v2` function call. - * - * Supports `jsonbPathQueryFirst`, `jsonbGet`, and `jsonbPathExists`. - * The column must have `searchableJson` enabled in its {@link EncryptedColumnConfig}. - */ -function createJsonbOperator( - operator: 'jsonbPathQueryFirst' | 'jsonbGet' | 'jsonbPathExists', - left: SQLWrapper, - right: unknown, - columnInfo: ColumnInfo, - encryptionClient: EncryptionClient, - defaultTable: EncryptedTable | undefined, - tableCache: Map>, -): Promise { - const { config } = columnInfo - const encryptedSelector = (value: unknown) => - sql`${bindIfParam(value, left)}::eql_v2_encrypted` - - if (!config?.searchableJson) { - throw new EncryptionOperatorError( - `The ${operator} operator requires searchableJson to be enabled on the column configuration.`, - { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, - operator, - }, - ) - } - - const executeFn = (encrypted: unknown) => { - if (encrypted === undefined) { + /** + * Reject a free-text needle the column's match index cannot answer. A needle + * shorter than the tokenizer's `token_length` yields an empty bloom filter, + * and `stored_bf @> '{}'` holds for every row — so without this the query + * silently returns the whole table. + */ + function requireAnswerableNeedle( + ctx: ColumnContext, + value: unknown, + operator: string, + ): void { + const match = ctx.indexes.match + if (!match) return + const reason = matchNeedleError(value, match) + if (reason) { throw new EncryptionOperatorError( - `Encryption failed for ${operator} operator`, - { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, - operator, - }, + `Operator "${operator}" cannot search column "${ctx.columnName}": ${reason}`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, ) } - switch (operator) { - case 'jsonbPathQueryFirst': - return sql`eql_v2.jsonb_path_query_first(${left}, ${encryptedSelector(encrypted)})` - case 'jsonbGet': - return sql`${left} -> ${encryptedSelector(encrypted)}` - case 'jsonbPathExists': - return sql`eql_v2.jsonb_path_exists(${left}, ${encryptedSelector(encrypted)})` - } } - return createLazyOperator( - operator, - left, - right, - executeFn, - true, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - undefined, - undefined, - queryTypes.steVecSelector, - ) as Promise -} - -// ============================================================================ -// Public API: createEncryptionOperators -// ============================================================================ - -/** - * Creates a set of encryption-aware operators that automatically encrypt values - * for encrypted columns before using them with Drizzle operators. - * - * For equality and text search operators (eq, ne, like, ilike, inArray, etc.): - * Values are encrypted and then passed to regular Drizzle operators, which use - * PostgreSQL's built-in operators for eql_v2_encrypted types. - * - * For order and range operators (gt, gte, lt, lte, between, notBetween): - * Values are encrypted and then use eql_v2.* functions (eql_v2.gt(), eql_v2.gte(), etc.) - * which are required for ORE (Order-Revealing Encryption) comparisons. - * - * @param encryptionClient - The EncryptionClient instance - * @returns An object with all Drizzle operators wrapped for encrypted columns - * - * @example - * ```ts - * // Initialize operators - * const ops = createEncryptionOperators(encryptionClient) - * - * // Equality search - automatically encrypts and uses PostgreSQL operators - * const results = await db - * .select() - * .from(usersTable) - * .where(await ops.eq(usersTable.email, 'user@example.com')) - * - * // Range query - automatically encrypts and uses eql_v2.gte() - * const olderUsers = await db - * .select() - * .from(usersTable) - * .where(await ops.gte(usersTable.age, 25)) - * ``` - */ -export function createEncryptionOperators(encryptionClient: EncryptionClient): { - // Comparison operators - /** - * Equality operator - encrypts value for encrypted columns. - * Requires either `equality` or `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users with a specific email address. - * ```ts - * const condition = await ops.eq(usersTable.email, 'user@example.com') - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - eq: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Not equal operator - encrypts value for encrypted columns. - * Requires either `equality` or `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users whose email address is not a specific value. - * ```ts - * const condition = await ops.ne(usersTable.email, 'user@example.com') - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - ne: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Greater than operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users older than a specific age. - * ```ts - * const condition = await ops.gt(usersTable.age, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - gt: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Greater than or equal operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users older than or equal to a specific age. - * ```ts - * const condition = await ops.gte(usersTable.age, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - gte: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Less than operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users younger than a specific age. - * ```ts - * const condition = await ops.lt(usersTable.age, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - lt: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Less than or equal operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users younger than or equal to a specific age. - * ```ts - * const condition = await ops.lte(usersTable.age, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - lte: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Between operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users within a specific age range. - * ```ts - * const condition = await ops.between(usersTable.age, 20, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - between: (left: SQLWrapper, min: unknown, max: unknown) => Promise | SQL - - /** - * Not between operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users outside a specific age range. - * ```ts - * const condition = await ops.notBetween(usersTable.age, 20, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - notBetween: ( - left: SQLWrapper, - min: unknown, - max: unknown, - ) => Promise | SQL - - /** - * Like operator for encrypted columns with free text search. - * Requires `freeTextSearch` to be set on {@link EncryptedColumnConfig}. - * - * > [!IMPORTANT] - * > Case sensitivity on encrypted columns depends on the {@link EncryptedColumnConfig}. - * > Ensure that the column is configured for case-insensitive search if needed. - * - * @example - * Select users with email addresses matching a pattern. - * ```ts - * const condition = await ops.like(usersTable.email, '%@example.com') - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - like: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * ILike operator for encrypted columns with free text search. - * Requires `freeTextSearch` to be set on {@link EncryptedColumnConfig}. - * - * > [!IMPORTANT] - * > Case sensitivity on encrypted columns depends on the {@link EncryptedColumnConfig}. - * > Ensure that the column is configured for case-insensitive search if needed. - * - * @example - * Select users with email addresses matching a pattern (case-insensitive). - * ```ts - * const condition = await ops.ilike(usersTable.email, '%@example.com') - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - ilike: (left: SQLWrapper, right: unknown) => Promise | SQL - notIlike: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * JSONB path query first operator for encrypted columns with searchable JSON. - * Requires `searchableJson` to be set on {@link EncryptedColumnConfig}. - * - * Encrypts the JSON path selector and calls `eql_v2.jsonb_path_query_first()`, - * casting the parameter to `eql_v2_encrypted`. - * - * @throws {EncryptionOperatorError} If the column does not have `searchableJson` enabled. - */ - jsonbPathQueryFirst: (left: SQLWrapper, right: unknown) => Promise - - /** - * JSONB get operator for encrypted columns with searchable JSON. - * Requires `searchableJson` to be set on {@link EncryptedColumnConfig}. - * - * Encrypts the JSON path selector and uses the `->` operator, - * casting the parameter to `eql_v2_encrypted`. - * - * @throws {EncryptionOperatorError} If the column does not have `searchableJson` enabled. - */ - jsonbGet: (left: SQLWrapper, right: unknown) => Promise - - /** - * JSONB path exists operator for encrypted columns with searchable JSON. - * Requires `searchableJson` to be set on {@link EncryptedColumnConfig}. - * - * Encrypts the JSON path selector and calls `eql_v2.jsonb_path_exists()`, - * casting the parameter to `eql_v2_encrypted`. - * - * @throws {EncryptionOperatorError} If the column does not have `searchableJson` enabled. - */ - jsonbPathExists: (left: SQLWrapper, right: unknown) => Promise - // Array operators - inArray: (left: SQLWrapper, right: unknown[] | SQLWrapper) => Promise - notInArray: (left: SQLWrapper, right: unknown[] | SQLWrapper) => Promise - // Sorting operators - asc: (column: SQLWrapper) => SQL - desc: (column: SQLWrapper) => SQL - and: ( - ...conditions: (SQL | SQLWrapper | Promise | undefined)[] - ) => Promise - or: ( - ...conditions: (SQL | SQLWrapper | Promise | undefined)[] - ) => Promise - // Operators that don't need encryption (pass through to Drizzle) - exists: typeof exists - notExists: typeof notExists - isNull: typeof isNull - isNotNull: typeof isNotNull - not: typeof not - // Array operators that work with arrays directly (not encrypted values) - arrayContains: typeof arrayContains - arrayContained: typeof arrayContained - arrayOverlaps: typeof arrayOverlaps -} { - // Create a cache for encrypted tables keyed by table name - const tableCache = new Map>() - const defaultTable: EncryptedTable | undefined = - undefined - - /** - * Equality operator - encrypts value and uses regular Drizzle operator - */ - const encryptedEq = ( - left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createComparisonOperator( - 'eq', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, + function operandFailure( + ctx: ColumnContext, + operator: string, + reason: string, + ): EncryptionOperatorError { + return new EncryptionOperatorError( + `Failed to encrypt query operand for "${ctx.columnName}": ${reason}`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, ) } /** - * Not equal operator - encrypts value and uses regular Drizzle operator + * Render a query term as a cast operand: `''::eql_v3.query_`. + * The cast is what reaches the bundle's `(domain, query_)` overloads — + * a bare `::jsonb` would hit the storage-domain overload, whose CHECK demands + * the ciphertext `c` a query term deliberately omits. `queryCast` is derived + * from the column's own domain (see `queryCastForDomain`), so `sql.raw` is safe. */ - const encryptedNe = ( - left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createComparisonOperator( - 'ne', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) + function castOperand( + ctx: ColumnContext, + operator: string, + term: EncryptedQueryResult, + ): SQL { + if (ctx.queryCast === null) { + throw operandFailure( + ctx, + operator, + `column domain "${ctx.builder.getEqlType()}" has no query operand type.`, + ) + } + return sql`${JSON.stringify(term)}::${sql.raw(ctx.queryCast)}` } - /** - * Greater than operator - uses eql_v2.gt() for encrypted columns with ORE index - */ - const encryptedGt = ( - left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createComparisonOperator( - 'gt', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) - } + async function encryptOperand( + ctx: ColumnContext, + value: unknown, + operator: string, + queryType: QueryTypeName, + opts?: EncryptionOperatorCallOpts, + ): Promise { + requireNonNullOperand(ctx, value, operator) - /** - * Greater than or equal operator - uses eql_v2.gte() for encrypted columns with ORE index - */ - const encryptedGte = ( - left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createComparisonOperator( - 'gte', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, + const result = await applyOperationOptions( + client.encryptQuery( + value as never, + { + table: ctx.table, + column: ctx.builder, + queryType, + } as never, + ), + opts, ) + if (result.failure) { + throw operandFailure(ctx, operator, result.failure.message) + } + return castOperand(ctx, operator, result.data) } /** - * Less than operator - uses eql_v2.lt() for encrypted columns with ORE index + * Encrypt a whole operand list in ONE `encryptQuery` batch crossing (rather + * than one per value). The batch is position-stable, so the returned terms + * align index-for-index with `values`; a response of a different length means + * the contract was violated and is rejected rather than silently truncating + * the predicate (which would widen an `inArray` or narrow a `notInArray`). + * Null operands are rejected up front, so no term is filtered out of the batch. */ - const encryptedLt = ( - left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createComparisonOperator( - 'lt', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) - } + async function encryptOperands( + ctx: ColumnContext, + values: unknown[], + operator: string, + queryType: QueryTypeName, + opts?: EncryptionOperatorCallOpts, + ): Promise { + for (const value of values) requireNonNullOperand(ctx, value, operator) - /** - * Less than or equal operator - uses eql_v2.lte() for encrypted columns with ORE index - */ - const encryptedLte = ( - left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createComparisonOperator( - 'lte', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, + const terms = values.map((value) => ({ + value, + column: ctx.builder, + table: ctx.table, + queryType, + })) + const result = await applyOperationOptions( + client.encryptQuery(terms as never), + opts, ) - } + if (result.failure) { + throw operandFailure(ctx, operator, result.failure.message) + } - /** - * Between operator - uses eql_v2.gte() and eql_v2.lte() for encrypted columns with ORE index - */ - const encryptedBetween = ( - left: SQLWrapper, - min: unknown, - max: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createRangeOperator( - 'between', - left, - min, - max, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) + const encrypted = result.data as EncryptedQueryResult[] + if (encrypted.length !== values.length) { + throw operandFailure( + ctx, + operator, + `batch query encryption returned ${encrypted.length} terms for ${values.length} values.`, + ) + } + return encrypted.map((term) => castOperand(ctx, operator, term)) } - /** - * Not between operator - uses eql_v2.gte() and eql_v2.lte() for encrypted columns with ORE index - */ - const encryptedNotBetween = ( - left: SQLWrapper, - min: unknown, - max: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createRangeOperator( - 'notBetween', - left, - min, - max, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) - } + const colSql = (column: SQLWrapper): SQL => sql`${column}` - /** - * Like operator - encrypts value and uses eql_v2.like() for encrypted columns with match index - */ - const encryptedLike = ( + async function equality( + op: EqualityOp, left: SQLWrapper, right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createTextSearchOperator( - 'like', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) + opts?: EncryptionOperatorCallOpts, + ): Promise { + const ctx = resolveContext(left, op) + requireIndex(ctx, EQUALITY_INDEXES, op, 'equality') + const enc = await encryptOperand(ctx, right, op, 'equality', opts) + return v3Dialect.equality(op, colSql(left), enc) } - /** - * Case-insensitive like operator - encrypts value and uses eql_v2.ilike() for encrypted columns with match index - */ - const encryptedIlike = ( + async function comparison( + op: ComparisonOp, left: SQLWrapper, right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createTextSearchOperator( - 'ilike', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) + opts?: EncryptionOperatorCallOpts, + ): Promise { + const ctx = resolveContext(left, op) + requireIndex(ctx, ORDERING_INDEXES, op, 'order/range') + const enc = await encryptOperand(ctx, right, op, 'orderAndRange', opts) + return v3Dialect.comparison(op, colSql(left), enc) } - /** - * Not like operator (case insensitive) - encrypts value and uses eql_v2.ilike() for encrypted columns with match index - */ - const encryptedNotIlike = ( + async function range( left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createTextSearchOperator( - 'notIlike', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) - } - - /** - * JSONB path query first operator - encrypts the selector and calls - * `eql_v2.jsonb_path_query_first()` for encrypted columns with searchable JSON. - */ - const encryptedJsonbPathQueryFirst = ( + min: unknown, + max: unknown, + negate: boolean, + operator: string, + opts?: EncryptionOperatorCallOpts, + ): Promise { + const ctx = resolveContext(left, operator) + requireIndex(ctx, ORDERING_INDEXES, operator, 'order/range') + // Independent operands — encrypt concurrently rather than paying two + // sequential round-trips to the crypto backend. + const [encMin, encMax] = await Promise.all([ + encryptOperand(ctx, min, operator, 'orderAndRange', opts), + encryptOperand(ctx, max, operator, 'orderAndRange', opts), + ]) + // `v3Dialect.range` is already parenthesised, so `NOT` binds to the whole + // conjunction without a wrapper here. + const condition = v3Dialect.range(colSql(left), encMin, encMax) + return negate ? sql`NOT ${condition}` : condition + } + + /** + * Fuzzy free-text token match on a `text_search`/`text_match` column. NOT + * containment: it tests whether the needle's downcased 3-gram set is a subset + * of the haystack's, via a bloom filter — order- and multiplicity-insensitive + * and one-sided (a `true` may be a false positive, a `false` never is). Emits + * `eql_v3.matches(col, operand)` (the SQL function's bundle name). + */ + async function matches( left: SQLWrapper, right: unknown, - ): Promise => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createJsonbOperator( - 'jsonbPathQueryFirst', - left, + operator: string, + opts?: EncryptionOperatorCallOpts, + ): Promise { + const ctx = resolveContext(left, operator) + requireIndex(ctx, MATCH_INDEXES, operator, 'free-text search') + // The answerable-needle rule applies (a sub-`token_length` needle blooms to + // nothing and would match every row); the `query_` cast reaches the + // match overload. + requireAnswerableNeedle(ctx, right, operator) + const enc = await encryptOperand( + ctx, right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, + operator, + 'freeTextSearch', + opts, ) + return v3Dialect.contains(colSql(left), enc) } /** - * JSONB get operator - encrypts the selector and uses the `->` operator - * for encrypted columns with searchable JSON. + * Exact encrypted-JSONB containment on an `eql_v3_json_search` (`ste_vec`) column: + * genuine jsonb `@>`, no false positives — hence it keeps the `contains` name. + * `eql_v3_json_search` has no `eql_v3.matches` overload; containment is the `@>` + * operator, whose `(eql_v3_json_search, eql_v3.query_json)` form takes a NARROWED + * query term (searchableJson → no ciphertext), cast to `eql_v3.query_json`. */ - const encryptedJsonbGet = ( + async function containsJsonOp( left: SQLWrapper, right: unknown, - ): Promise => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createJsonbOperator( - 'jsonbGet', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, + operator: string, + opts?: EncryptionOperatorCallOpts, + ): Promise { + const ctx = resolveContext(left, operator) + requireIndex(ctx, JSON_CONTAINMENT_INDEXES, operator, 'JSON containment') + const needle = await encryptJsonContainmentTerm(ctx, right, operator, opts) + return v3Dialect.containsJson(colSql(left), needle) + } + + /** + * Build a `query_json` containment needle for a `json` column — the JSON query + * term carries no ciphertext and satisfies the `eql_v3.query_json` CHECK the + * `@>` overload needs. Cast here (not by `queryCastForDomain`): a json column's + * domain is `eql_v3_json_search` but its query operand type is the irregular + * `eql_v3.query_json`. + */ + async function encryptJsonContainmentTerm( + ctx: ColumnContext, + value: unknown, + operator: string, + opts?: EncryptionOperatorCallOpts, + ): Promise { + requireNonNullOperand(ctx, value, operator) + // Reject the empty-object needle. `doc @> '{}'` holds for EVERY document + // (jsonb `{} ⊆ anything`), so `contains(col, {})` would silently return the + // whole table — the same whole-table footgun the bloom path guards against + // with `requireAnswerableNeedle`. An accidental empty filter is a bug, not a + // match-all request; callers wanting every row should omit the predicate. + if ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).length === 0 + ) { + throw new EncryptionOperatorError( + `Operator "${operator}" cannot take an empty object needle on column "${ctx.columnName}": it matches every row. Pass a non-empty sub-object, or omit the predicate to select all rows.`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, + ) + } + const result = await applyOperationOptions( + client.encryptQuery( + value as never, + { + table: ctx.table, + column: ctx.builder, + queryType: 'searchableJson', + } as never, + ), + opts, ) - } - - /** - * JSONB path exists operator - encrypts the selector and calls - * `eql_v2.jsonb_path_exists()` for encrypted columns with searchable JSON. - */ - const encryptedJsonbPathExists = ( - left: SQLWrapper, - right: unknown, - ): Promise => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createJsonbOperator( - 'jsonbPathExists', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, + if (result.failure) { + throw operandFailure(ctx, operator, result.failure.message) + } + return sql`${JSON.stringify(result.data)}::eql_v3.query_json` + } + + /** + * JSONPath selector-with-constraint on an `eql_v3_json_search` (`ste_vec`) + * column. Equality uses a value-selector containment needle, allowing the + * functional GIN index to answer the query. Ordering extracts the path entry + * with a selector hash and compares it to a ciphertext-free scalar ordering + * term. No storage ciphertext is placed in the WHERE clause. + */ + async function selectorCompare( + col: SQLWrapper, + path: string, + op: EqualityOp | ComparisonOp, + value: unknown, + operator: string, + opts?: EncryptionOperatorCallOpts, + ): Promise { + const ctx = resolveContext(col, operator) + requireIndex( + ctx, + JSON_CONTAINMENT_INDEXES, + operator, + 'JSON selector (searchableJson)', ) - } - - /** - * In array operator - encrypts all values in the array - */ - const encryptedInArray = async ( - left: SQLWrapper, - right: unknown[] | SQLWrapper, - ): Promise => { - // If right is a SQLWrapper (subquery), pass through to Drizzle - if (isSQLWrapper(right)) { - return inArray(left, right as unknown as Parameters[1]) - } + requireNonNullOperand(ctx, value, operator) - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - - if (!columnInfo.config?.equality || !Array.isArray(right)) { - return inArray(left, right as unknown[]) + // A selector compares a scalar leaf — reject non-scalars / non-orderable + // types up front with a clear error, not a deferred DB failure. + const ordering = op !== 'eq' && op !== 'ne' + const leafReason = unsupportedLeafReason(value, ordering) + if (leafReason) { + throw new EncryptionOperatorError( + `Operator "${operator}" cannot compare column "${ctx.columnName}": ${leafReason}`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, + ) } - // Encrypt all values in the array in a single batch - const encryptedValues = await encryptValues( - encryptionClient, - right.map((value) => ({ - value, - column: left, - queryType: queryTypes.equality, - })), - defaultTable, - tableCache, - ) - - // Use regular eq for each encrypted value - PostgreSQL operators handle it - const conditions = encryptedValues - .filter((encrypted) => encrypted !== undefined) - .map((encrypted) => eq(left, encrypted)) - - if (conditions.length === 0) { - return sql`false` + // Surface path-validation failures as EncryptionOperatorError with context. + let segments: string[] + try { + segments = parseSelectorSegments(path) + } catch (err) { + throw new EncryptionOperatorError( + `Operator "${operator}" on column "${ctx.columnName}": ${err instanceof Error ? err.message : String(err)}`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, + ) } - const combined = or(...conditions) - return combined ?? sql`false` - } + const canonicalPath = jsonPathOf(segments) - /** - * Not in array operator - */ - const encryptedNotInArray = async ( - left: SQLWrapper, - right: unknown[] | SQLWrapper, - ): Promise => { - // If right is a SQLWrapper (subquery), pass through to Drizzle - if (isSQLWrapper(right)) { - return notInArray( - left, - right as unknown as Parameters[1], + if (op === 'eq' || op === 'ne') { + const result = await applyOperationOptions( + client.encryptQuery( + { path: canonicalPath, value } as never, + { + table: ctx.table, + column: ctx.builder, + queryType: 'steVecValueSelector', + } as never, + ), + opts, + ) + if (result.failure) { + throw operandFailure(ctx, operator, result.failure.message) + } + const contains = v3Dialect.containsJson( + colSql(col), + sql`${JSON.stringify(result.data)}::eql_v3.query_json`, + ) + return op === 'eq' + ? contains + : sql`(NOT ${contains} OR ${colSql(col)} IS NULL)` + } + + // Selector hashing and scalar-term encryption are independent, so order + // them concurrently. `steVecTerm` accepts only the JSON-orderable scalar + // families (string and number), enforced above. + const [selResult, termResult] = await Promise.all([ + applyOperationOptions( + client.encryptQuery( + canonicalPath as never, + { + table: ctx.table, + column: ctx.builder, + queryType: 'steVecSelector', + } as never, + ), + opts, + ), + applyOperationOptions( + client.encryptQuery( + value as never, + { + table: ctx.table, + column: ctx.builder, + queryType: 'steVecTerm', + } as never, + ), + opts, + ), + ]) + if (selResult.failure) { + throw operandFailure(ctx, operator, selResult.failure.message) + } + if (termResult.failure) { + throw operandFailure(ctx, operator, termResult.failure.message) + } + + // A v3 selector term is the bare HMAC hash string; guard the shape so a + // wrapped envelope can't silently bind as a JSON blob and match no rows. + const selValue = selResult.data + if (typeof selValue !== 'string') { + throw operandFailure( + ctx, + operator, + `expected a bare selector hash, got ${typeof selValue}.`, ) } - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - - if (!columnInfo.config?.equality || !Array.isArray(right)) { - return notInArray(left, right as unknown[]) - } + const selSql = sql`${selValue}::text` + const leftEntry = v3Dialect.selectorEntry(colSql(col), selSql) + const termCast = + typeof value === 'string' + ? 'eql_v3.query_text_ord' + : 'eql_v3.query_double_ord' + const rightTerm = sql`${JSON.stringify(termResult.data)}::${sql.raw(termCast)}` + return v3Dialect.comparison(op, leftEntry, rightTerm) + } - // Encrypt all values in the array in a single batch - const encryptedValues = await encryptValues( - encryptionClient, - right.map((value) => ({ - value, - column: left, - queryType: queryTypes.equality, - })), - defaultTable, - tableCache, + /** Comparison methods bound to a `col->'path'` selector, mirroring the scalar + * operators. Async: each encrypts its operand. */ + function selectorOps(col: SQLWrapper, path: string) { + const at = + (op: EqualityOp | ComparisonOp) => + (value: unknown, opts?: EncryptionOperatorCallOpts) => + selectorCompare(col, path, op, value, `selector(${path}).${op}`, opts) + return { + /** `col->'path' = value` (encrypted equality at the selector). A row whose + * document lacks `path` is excluded (it is not equal to `value`). */ + eq: at('eq'), + /** `col->'path' <> value`, INCLUDING rows whose document lacks `path` + * ("not equal to value" covers "has no value"). */ + ne: at('ne'), + /** `col->'path' > value` (encrypted ordering at the selector). */ + gt: at('gt'), + /** `col->'path' >= value`. */ + gte: at('gte'), + /** `col->'path' < value`. */ + lt: at('lt'), + /** `col->'path' <= value`. */ + lte: at('lte'), + /** Order rows ascending by the encrypted scalar at `path`. Missing paths + * produce SQL NULL and follow PostgreSQL's normal NULL ordering. */ + asc: (opts?: EncryptionOperatorCallOpts) => + selectorOrder(col, path, 'asc', opts), + /** Order rows descending by the encrypted scalar at `path`. Missing paths + * produce SQL NULL and follow PostgreSQL's normal NULL ordering. */ + desc: (opts?: EncryptionOperatorCallOpts) => + selectorOrder(col, path, 'desc', opts), + } + } + + /** Build `ORDER BY eql_v3.ord_term(col -> selector::text)`. + * The selector is encrypted, but the extracted SteVec entry already carries + * its OPE ordering term, so no plaintext comparison operand is needed. */ + async function selectorOrder( + col: SQLWrapper, + path: string, + direction: 'asc' | 'desc', + opts?: EncryptionOperatorCallOpts, + ): Promise { + const operator = `selector(${path}).${direction}` + const ctx = resolveContext(col, operator) + requireIndex( + ctx, + JSON_CONTAINMENT_INDEXES, + operator, + 'JSON selector (searchableJson)', ) - // Use regular ne for each encrypted value - PostgreSQL operators handle it - const conditions = encryptedValues - .filter((encrypted) => encrypted !== undefined) - .map((encrypted) => ne(left, encrypted)) - - if (conditions.length === 0) { - return sql`true` + let canonicalPath: string + try { + canonicalPath = jsonPathOf(parseSelectorSegments(path)) + } catch (err) { + throw new EncryptionOperatorError( + `Operator "${operator}" on column "${ctx.columnName}": ${err instanceof Error ? err.message : String(err)}`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, + ) } - const combined = and(...conditions) - return combined ?? sql`true` - } - - /** - * Ascending order helper - uses eql_v2.order_by() for encrypted columns with ORE index - */ - const encryptedAsc = (column: SQLWrapper): SQL => { - const columnInfo = getColumnInfo(column, defaultTable, tableCache) - - if (columnInfo.config?.orderAndRange) { - return asc(sql`eql_v2.order_by(${column})`) + const result = await applyOperationOptions( + client.encryptQuery( + canonicalPath as never, + { + table: ctx.table, + column: ctx.builder, + queryType: 'steVecSelector', + } as never, + ), + opts, + ) + if (result.failure) { + throw operandFailure(ctx, operator, result.failure.message) } - - return asc(column) - } - - /** - * Descending order helper - uses eql_v2.order_by() for encrypted columns with ORE index - */ - const encryptedDesc = (column: SQLWrapper): SQL => { - const columnInfo = getColumnInfo(column, defaultTable, tableCache) - - if (columnInfo.config?.orderAndRange) { - return desc(sql`eql_v2.order_by(${column})`) + if (typeof result.data !== 'string') { + throw operandFailure( + ctx, + operator, + `expected a bare selector hash, got ${typeof result.data}.`, + ) } - return desc(column) + const entry = v3Dialect.selectorEntry( + colSql(col), + sql`${result.data}::text`, + ) + const term = v3Dialect.orderBy(entry, 'ope') + return direction === 'asc' ? asc(term) : desc(term) } - /** - * Batched AND operator - collects lazy operators, batches encryption, and combines conditions - */ - const encryptedAnd = async ( - ...conditions: (SQL | SQLWrapper | Promise | undefined)[] - ): Promise => { - // Single pass: separate lazy operators from regular conditions - const lazyOperators: LazyOperator[] = [] - const regularConditions: (SQL | SQLWrapper | undefined)[] = [] - const regularPromises: Promise[] = [] - - for (const condition of conditions) { - if (condition === undefined) { - continue - } - - if (isLazyOperator(condition)) { - lazyOperators.push(condition) - } else if (condition instanceof Promise) { - // Check if promise is also a lazy operator - if (isLazyOperator(condition)) { - lazyOperators.push(condition) - } else { - regularPromises.push(condition) - } - } else { - regularConditions.push(condition) - } - } - - // If there are no lazy operators, just use Drizzle's and() - if (lazyOperators.length === 0) { - const allConditions: (SQL | SQLWrapper | undefined)[] = [ - ...regularConditions, - ...(await Promise.all(regularPromises)), - ] - return and(...allConditions) ?? sql`true` - } - - // Single pass: collect all values to encrypt with metadata - const valuesToEncrypt: Array<{ - value: unknown - column: SQLWrapper - columnInfo: ColumnInfo - queryType?: QueryTypeName - lazyOpIndex: number - isMin?: boolean - isMax?: boolean - }> = [] - - for (let i = 0; i < lazyOperators.length; i++) { - const lazyOp = lazyOperators[i] - if (!lazyOp.needsEncryption) { - continue - } - - if (lazyOp.min !== undefined && lazyOp.max !== undefined) { - valuesToEncrypt.push({ - value: lazyOp.min, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - isMin: true, - }) - valuesToEncrypt.push({ - value: lazyOp.max, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - isMax: true, - }) - } else if (lazyOp.right !== undefined) { - valuesToEncrypt.push({ - value: lazyOp.right, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - }) - } + async function inArrayOp( + left: SQLWrapper, + values: unknown[], + negate: boolean, + operator: string, + opts?: EncryptionOperatorCallOpts, + ): Promise { + const ctx = resolveContext(left, operator) + if (values.length === 0) { + throw new EncryptionOperatorError( + `Operator "${operator}" requires a non-empty list of values for column "${ctx.columnName}".`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, + ) } - - // Batch encrypt all values - const encryptedResults = await encryptValues( - encryptionClient, - valuesToEncrypt.map((v) => ({ - value: v.value, - column: v.column, - queryType: v.queryType, - })), - defaultTable, - tableCache, + // Gate and resolve the context once for the whole list, then encrypt it in + // a single `encryptQuery` batch crossing. + requireIndex(ctx, EQUALITY_INDEXES, operator, 'equality') + const op: EqualityOp = negate ? 'ne' : 'eq' + const encrypted = await encryptOperands( + ctx, + values, + operator, + 'equality', + opts, ) - - // Group encrypted values by lazy operator index - const encryptedByLazyOp = new Map< - number, - { value?: unknown; min?: unknown; max?: unknown } - >() - - for (let i = 0; i < valuesToEncrypt.length; i++) { - const { lazyOpIndex, isMin, isMax } = valuesToEncrypt[i] - const encrypted = encryptedResults[i] - - let group = encryptedByLazyOp.get(lazyOpIndex) - if (!group) { - group = {} - encryptedByLazyOp.set(lazyOpIndex, group) - } - - if (isMin) { - group.min = encrypted - } else if (isMax) { - group.max = encrypted - } else { - group.value = encrypted - } - } - - // Execute all lazy operators with their encrypted values - const sqlConditions: SQL[] = [] - for (let i = 0; i < lazyOperators.length; i++) { - const lazyOp = lazyOperators[i] - const encrypted = encryptedByLazyOp.get(i) - - let sqlCondition: SQL - if (lazyOp.needsEncryption && encrypted) { - const encryptedValues: Array<{ value: unknown; encrypted: unknown }> = - [] - if (encrypted.value !== undefined) { - encryptedValues.push({ - value: lazyOp.right, - encrypted: encrypted.value, - }) - } - if (encrypted.min !== undefined) { - encryptedValues.push({ value: lazyOp.min, encrypted: encrypted.min }) - } - if (encrypted.max !== undefined) { - encryptedValues.push({ value: lazyOp.max, encrypted: encrypted.max }) - } - sqlCondition = await executeLazyOperator(lazyOp, encryptedValues) - } else { - sqlCondition = lazyOp.execute(lazyOp.right) - } - - sqlConditions.push(sqlCondition) - } - - // Await any regular promises - const regularPromisesResults = await Promise.all(regularPromises) - - // Combine all conditions - const allConditions: (SQL | SQLWrapper | undefined)[] = [ - ...regularConditions, - ...sqlConditions, - ...regularPromisesResults, - ] - - return and(...allConditions) ?? sql`true` + const conditions = encrypted.map((enc) => + v3Dialect.equality(op, colSql(left), enc), + ) + // The empty-list guard above leaves `conditions` non-empty, so `and`/`or` + // never return undefined here. + return (negate ? and(...conditions) : or(...conditions)) as SQL } - /** - * Batched OR operator - collects lazy operators, batches encryption, and combines conditions - */ - const encryptedOr = async ( - ...conditions: (SQL | SQLWrapper | Promise | undefined)[] - ): Promise => { - const lazyOperators: LazyOperator[] = [] - const regularConditions: (SQL | SQLWrapper | undefined)[] = [] - const regularPromises: Promise[] = [] - - for (const condition of conditions) { - if (condition === undefined) { - continue - } - - if (isLazyOperator(condition)) { - lazyOperators.push(condition) - } else if (condition instanceof Promise) { - if (isLazyOperator(condition)) { - lazyOperators.push(condition) - } else { - regularPromises.push(condition) - } - } else { - regularConditions.push(condition) - } - } - - if (lazyOperators.length === 0) { - const allConditions: (SQL | SQLWrapper | undefined)[] = [ - ...regularConditions, - ...(await Promise.all(regularPromises)), - ] - return or(...allConditions) ?? sql`false` - } - - const valuesToEncrypt: Array<{ - value: unknown - column: SQLWrapper - columnInfo: ColumnInfo - queryType?: QueryTypeName - lazyOpIndex: number - isMin?: boolean - isMax?: boolean - }> = [] - - for (let i = 0; i < lazyOperators.length; i++) { - const lazyOp = lazyOperators[i] - if (!lazyOp.needsEncryption) { - continue - } - - if (lazyOp.min !== undefined && lazyOp.max !== undefined) { - valuesToEncrypt.push({ - value: lazyOp.min, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - isMin: true, - }) - valuesToEncrypt.push({ - value: lazyOp.max, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - isMax: true, - }) - } else if (lazyOp.right !== undefined) { - valuesToEncrypt.push({ - value: lazyOp.right, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - }) - } - } + function orderTerm(column: SQLWrapper, operator: string): SQL { + const ctx = resolveContext(column, operator) + requireIndex(ctx, ORDERING_INDEXES, operator, 'order/range') + return v3Dialect.orderBy(colSql(column), ctx.indexes.ore ? 'ore' : 'ope') + } - const encryptedResults = await encryptValues( - encryptionClient, - valuesToEncrypt.map((v) => ({ - value: v.value, - column: v.column, - queryType: v.queryType, - })), - defaultTable, - tableCache, + async function combine( + joiner: typeof and, + empty: SQL, + conditions: (SQL | SQLWrapper | Promise | undefined)[], + ): Promise { + const present = conditions.filter( + (c): c is SQL | SQLWrapper | Promise => c !== undefined, ) - - const encryptedByLazyOp = new Map< - number, - { value?: unknown; min?: unknown; max?: unknown } - >() - - for (let i = 0; i < valuesToEncrypt.length; i++) { - const { lazyOpIndex, isMin, isMax } = valuesToEncrypt[i] - const encrypted = encryptedResults[i] - - let group = encryptedByLazyOp.get(lazyOpIndex) - if (!group) { - group = {} - encryptedByLazyOp.set(lazyOpIndex, group) - } - - if (isMin) { - group.min = encrypted - } else if (isMax) { - group.max = encrypted - } else { - group.value = encrypted - } - } - - const sqlConditions: SQL[] = [] - for (let i = 0; i < lazyOperators.length; i++) { - const lazyOp = lazyOperators[i] - const encrypted = encryptedByLazyOp.get(i) - - let sqlCondition: SQL - if (lazyOp.needsEncryption && encrypted) { - const encryptedValues: Array<{ value: unknown; encrypted: unknown }> = - [] - if (encrypted.value !== undefined) { - encryptedValues.push({ - value: lazyOp.right, - encrypted: encrypted.value, - }) - } - if (encrypted.min !== undefined) { - encryptedValues.push({ value: lazyOp.min, encrypted: encrypted.min }) - } - if (encrypted.max !== undefined) { - encryptedValues.push({ value: lazyOp.max, encrypted: encrypted.max }) - } - sqlCondition = await executeLazyOperator(lazyOp, encryptedValues) - } else { - sqlCondition = lazyOp.execute(lazyOp.right) - } - - sqlConditions.push(sqlCondition) - } - - const regularPromisesResults = await Promise.all(regularPromises) - - const allConditions: (SQL | SQLWrapper | undefined)[] = [ - ...regularConditions, - ...sqlConditions, - ...regularPromisesResults, - ] - - return or(...allConditions) ?? sql`false` + const resolved = await Promise.all(present) + return joiner(...resolved) ?? empty } return { - // Comparison operators - eq: encryptedEq, - ne: encryptedNe, - gt: encryptedGt, - gte: encryptedGte, - lt: encryptedLt, - lte: encryptedLte, - - // Range operators - between: encryptedBetween, - notBetween: encryptedNotBetween, - - // Text search operators - like: encryptedLike, - ilike: encryptedIlike, - notIlike: encryptedNotIlike, - - // Searchable JSON operators - jsonbPathQueryFirst: encryptedJsonbPathQueryFirst, - jsonbGet: encryptedJsonbGet, - jsonbPathExists: encryptedJsonbPathExists, - - // Array operators - inArray: encryptedInArray, - notInArray: encryptedNotInArray, - - // Sorting operators - asc: encryptedAsc, - desc: encryptedDesc, - - // AND operator - batches encryption operations - and: encryptedAnd, - - // OR operator - batches encryption operations - or: encryptedOr, - - // Operators that don't need encryption (pass through to Drizzle) - exists, - notExists, + /** Equality: `column = value`. Encrypts `r` and emits `eql_v3.eq`. + * Requires a `unique` or `ore` index on the column. */ + eq: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + equality('eq', l, r, opts), + /** Inequality: `column <> value`. Encrypts `r` and emits `eql_v3.neq`. + * Requires a `unique` or `ore` index on the column. */ + ne: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + equality('ne', l, r, opts), + /** Greater-than: `column > value`. Encrypts `r` and emits `eql_v3.gt`. + * Requires an `ore` (order/range) index. */ + gt: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + comparison('gt', l, r, opts), + /** Greater-than-or-equal: `column >= value`. Encrypts `r` and emits + * `eql_v3.gte`. Requires an `ore` (order/range) index. */ + gte: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + comparison('gte', l, r, opts), + /** Less-than: `column < value`. Encrypts `r` and emits `eql_v3.lt`. + * Requires an `ore` (order/range) index. */ + lt: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + comparison('lt', l, r, opts), + /** Less-than-or-equal: `column <= value`. Encrypts `r` and emits + * `eql_v3.lte`. Requires an `ore` (order/range) index. */ + lte: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + comparison('lte', l, r, opts), + /** Inclusive range `min <= column <= max`. Encrypts both bounds + * concurrently. Requires an `ore` (order/range) index. */ + between: ( + l: SQLWrapper, + min: unknown, + max: unknown, + opts?: EncryptionOperatorCallOpts, + ) => range(l, min, max, false, 'between', opts), + /** Negated inclusive range `NOT (min <= column <= max)`. Encrypts both + * bounds concurrently. Requires an `ore` (order/range) index. */ + notBetween: ( + l: SQLWrapper, + min: unknown, + max: unknown, + opts?: EncryptionOperatorCallOpts, + ) => range(l, min, max, true, 'notBetween', opts), + /** Fuzzy free-text token match — the needle's 3-gram set is (bloom-)tested + * as a subset of the column's. NOT containment: order/multiplicity- + * insensitive and one-sided (a `true` may be a false positive). Encrypts + * `r`. Requires a `match` (free-text search) index. */ + matches: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + matches(l, r, 'matches', opts), + /** Exact encrypted-JSONB containment (`@>`): matches rows whose document + * contains the given sub-object. No false positives. Encrypts `r`. Requires + * a `ste_vec` index (a `types.Json` column). */ + contains: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + containsJsonOp(l, r, 'contains', opts), + /** JSONPath selector-with-constraint on a `types.Json` (`ste_vec`) column. + * Returns comparison and ordering methods bound to `col->'path'` — e.g. + * `await ops.selector(users.doc, '$.age').gt(21)` emits + * `col->'' > `, while `.asc()` emits + * `ORDER BY eql_v3.ord_term(col->'')`. Its unique power over `contains` + * is ordering at a path (`gt`/`gte`/`lt`/`lte` and `asc`/`desc`); `eq`/`ne` + * are also provided. Dot-notation object paths only in v1. */ + selector: (l: SQLWrapper, path: string) => selectorOps(l, path), + /** Membership: ORs one encrypted `eq` term per value. The whole list is + * encrypted in one `encryptQuery` batch crossing. Rejects an empty list; + * requires a `unique` or `ore` index. */ + inArray: ( + l: SQLWrapper, + values: unknown[], + opts?: EncryptionOperatorCallOpts, + ) => inArrayOp(l, values, false, 'inArray', opts), + /** Non-membership: ANDs one encrypted `ne` term per value. The whole list + * is encrypted in one `encryptQuery` batch crossing. Rejects an empty list; + * requires a `unique` or `ore` index. */ + notInArray: ( + l: SQLWrapper, + values: unknown[], + opts?: EncryptionOperatorCallOpts, + ) => inArrayOp(l, values, true, 'notInArray', opts), + /** Ascending order by the encrypted order term (`eql_v3.ord_term` / + * `eql_v3.ord_term_ore`, by the column's ordering flavour). + * Synchronous (no operand to encrypt). Requires an ordering index. */ + asc: (c: SQLWrapper) => asc(orderTerm(c, 'asc')), + /** Descending order by the encrypted order term (`eql_v3.ord_term` / + * `eql_v3.ord_term_ore`, by the column's ordering flavour). + * Synchronous (no operand to encrypt). Requires an ordering index. */ + desc: (c: SQLWrapper) => desc(orderTerm(c, 'desc')), + /** Conjunction of the given conditions, awaiting any async operands and + * dropping `undefined`. Empty input resolves to `true`. */ + and: (...conds: (SQL | SQLWrapper | Promise | undefined)[]) => + combine(and, sql`true`, conds), + /** Disjunction of the given conditions, awaiting any async operands and + * dropping `undefined`. Empty input resolves to `false`. */ + or: (...conds: (SQL | SQLWrapper | Promise | undefined)[]) => + combine(or, sql`false`, conds), + /** Drizzle's `isNull`, re-exported unchanged — `column IS NULL` needs no + * encryption and works on any (nullable) encrypted column. */ isNull, + /** Drizzle's `isNotNull`, re-exported unchanged — `column IS NOT NULL` + * needs no encryption. */ isNotNull, + /** Drizzle's `not`, re-exported unchanged — negates an already-built + * (encrypted) predicate. Safe over any operator here, including `between`, + * whose fragment is self-parenthesising. */ not, - // Array operators that work with arrays directly (not encrypted values) - arrayContains, - arrayContained, - arrayOverlaps, + /** Drizzle's `exists`, re-exported unchanged — for correlated subqueries. */ + exists, + /** Drizzle's `notExists`, re-exported unchanged — for correlated + * subqueries. */ + notExists, } } diff --git a/packages/stack-drizzle/src/schema-extraction.ts b/packages/stack-drizzle/src/schema-extraction.ts index 90bb3ec1e..7e0b4226f 100644 --- a/packages/stack-drizzle/src/schema-extraction.ts +++ b/packages/stack-drizzle/src/schema-extraction.ts @@ -1,130 +1,50 @@ import { - type EncryptedColumn, - type EncryptedTable, - encryptedColumn, + type AnyEncryptedV3Column, + type AnyV3Table, encryptedTable, -} from '@cipherstash/stack/schema' -import type { PgCustomColumn, PgTable } from 'drizzle-orm/pg-core' -import { getEncryptedColumnConfig } from './index.js' +} from '@cipherstash/stack/eql/v3' +import type { PgTable } from 'drizzle-orm/pg-core' +import { getEqlV3Column } from './column.js' + +/** Drizzle stashes the SQL table name on this well-known symbol key. */ +const DRIZZLE_NAME = Symbol.for('drizzle:Name') /** - * Extracts the encrypted column keys from a Drizzle table type. - * Columns created with `encryptedType` are `PgCustomColumn` instances; - * this picks only those keys and maps them to `EncryptedColumn`. + * Read the SQL table name Drizzle stashes on a `pgTable`. Returns `undefined` + * for a non-object or a table not built with `pgTable()`. Shared by + * {@link extractEncryptionSchema} and the operator factory so the + * symbol-key introspection lives in exactly one place. */ -// biome-ignore lint/suspicious/noExplicitAny: PgCustomColumn requires a wide generic -type DrizzleEncryptedSchema = { - [K in keyof T as T[K] extends PgCustomColumn - ? K - : never]: EncryptedColumn +export function getDrizzleTableName(table: unknown): string | undefined { + if (!table || typeof table !== 'object') return undefined + const name = (table as Record)[DRIZZLE_NAME] + return typeof name === 'string' ? name : undefined } -/** - * Extracts an encryption schema from a Drizzle table definition. - * This function identifies columns created with `encryptedType` and - * builds a corresponding `EncryptedTable` with `encryptedColumn` definitions. - * - * @param table - The Drizzle table definition - * @returns A EncryptedTable that can be used with encryption client initialization - * - * @example - * ```ts - * const drizzleUsersTable = pgTable('users', { - * email: encryptedType('email', { freeTextSearch: true, equality: true }), - * age: encryptedType('age', { dataType: 'number', orderAndRange: true }), - * }) - * - * const encryptionSchema = extractEncryptionSchema(drizzleUsersTable) - * const client = await createEncryptionClient({ schemas: [encryptionSchema.build()] }) - * ``` - */ -// We use any for the PgTable generic because we need to access Drizzle's internal properties -// biome-ignore lint/suspicious/noExplicitAny: Drizzle table types don't expose Symbol properties -export function extractEncryptionSchema>( - table: T, -): EncryptedTable> & DrizzleEncryptedSchema { - // Drizzle tables store the name in a Symbol property - // biome-ignore lint/suspicious/noExplicitAny: Drizzle tables don't expose Symbol properties in types - const tableName = (table as any)[Symbol.for('drizzle:Name')] as - | string - | undefined +export function extractEncryptionSchema(table: PgTable): AnyV3Table { + const tableName = getDrizzleTableName(table) if (!tableName) { throw new Error( - 'Unable to extract table name from Drizzle table. Ensure you are using a table created with pgTable().', + 'Unable to read table name from Drizzle table. Use a table created with pgTable().', ) } - const columns: Record = {} - - // Iterate through table columns - for (const [columnName, column] of Object.entries(table)) { - // Skip if it's not a column (could be methods or other properties) - if (typeof column !== 'object' || column === null) { - continue - } - - // Check if this column has encrypted configuration - const config = getEncryptedColumnConfig(columnName, column) - - if (config) { - // Extract the actual column name from the column object (not the schema key) - // Drizzle columns have a 'name' property that contains the actual database column name - const actualColumnName = column.name || config.name - - // This is an encrypted column - build encryptedColumn using the actual column name - const csCol = encryptedColumn(actualColumnName) - - // Apply data type - if (config.dataType && config.dataType !== 'string') { - csCol.dataType(config.dataType) - } - - // Apply indexes based on configuration - if (config.orderAndRange) { - csCol.orderAndRange() - } - - if (config.equality) { - if (Array.isArray(config.equality)) { - // Custom token filters - csCol.equality(config.equality) - } else { - // Default equality (boolean true) - csCol.equality() - } - } - - if (config.freeTextSearch) { - if (typeof config.freeTextSearch === 'object') { - // Custom match options - csCol.freeTextSearch(config.freeTextSearch) - } else { - // Default freeTextSearch (boolean true) - csCol.freeTextSearch() - } - } - - if (config.searchableJson) { - if (config.dataType !== 'json') { - throw new Error( - `Column "${columnName}" has searchableJson enabled but dataType is "${config.dataType ?? 'string'}". searchableJson requires dataType: 'json'.`, - ) - } - csCol.searchableJson() - } - - columns[actualColumnName] = csCol - } + const columns: Record = {} + for (const [property, column] of Object.entries(table)) { + if (typeof column !== 'object' || column === null) continue + const columnName = + 'name' in column && typeof column.name === 'string' + ? column.name + : property + const builder = getEqlV3Column(columnName, column) + if (builder) columns[property] = builder } if (Object.keys(columns).length === 0) { throw new Error( - `No encrypted columns found in table "${tableName}". Use encryptedType() to define encrypted columns.`, + `No encrypted v3 columns found in table "${tableName}". Declare columns with the v3 drizzle \`types\` namespace.`, ) } - return encryptedTable(tableName, columns) as EncryptedTable< - DrizzleEncryptedSchema - > & - DrizzleEncryptedSchema + return encryptedTable(tableName, columns) } diff --git a/packages/stack-drizzle/src/v3/sql-dialect.ts b/packages/stack-drizzle/src/sql-dialect.ts similarity index 100% rename from packages/stack-drizzle/src/v3/sql-dialect.ts rename to packages/stack-drizzle/src/sql-dialect.ts diff --git a/packages/stack-drizzle/src/v3/types.ts b/packages/stack-drizzle/src/types.ts similarity index 100% rename from packages/stack-drizzle/src/v3/types.ts rename to packages/stack-drizzle/src/types.ts diff --git a/packages/stack-drizzle/src/v3/index.ts b/packages/stack-drizzle/src/v3/index.ts deleted file mode 100644 index 5e6c042c7..000000000 --- a/packages/stack-drizzle/src/v3/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { EqlV3CodecError, v3FromDriver, v3ToDriver } from './codec.js' -export { getEqlV3Column, isEqlV3Column, makeEqlV3Column } from './column.js' -export { encryptedIndexes } from './indexes.js' -export { - createEncryptionOperatorsV3, - EncryptionOperatorError, -} from './operators.js' -export { extractEncryptionSchemaV3 } from './schema-extraction.js' -export { types } from './types.js' diff --git a/packages/stack-drizzle/src/v3/operators.ts b/packages/stack-drizzle/src/v3/operators.ts deleted file mode 100644 index f2e113855..000000000 --- a/packages/stack-drizzle/src/v3/operators.ts +++ /dev/null @@ -1,959 +0,0 @@ -import type { Result } from '@byteslice/result' -import type { AuditConfig } from '@cipherstash/stack/adapter-kit' -import { - jsonPathOf, - matchNeedleError, - parseSelectorSegments, - reconstructSelectorDocument, - stripDomainSchema, - unsupportedLeafReason, -} from '@cipherstash/stack/adapter-kit' -import type { - AnyEncryptedV3Column, - AnyV3Table, -} from '@cipherstash/stack/eql/v3' -import type { EncryptionError } from '@cipherstash/stack/errors' -import type { LockContext } from '@cipherstash/stack/identity' -import type { ColumnSchema } from '@cipherstash/stack/schema' -import type { - EncryptedQueryResult, - QueryTypeName, -} from '@cipherstash/stack/types' -import { - and, - asc, - Column, - desc, - exists, - is, - isNotNull, - isNull, - not, - notExists, - or, - type SQL, - type SQLWrapper, - sql, -} from 'drizzle-orm' -import type { PgTable } from 'drizzle-orm/pg-core' -import { getEqlV3Column } from './column.js' -import { - extractEncryptionSchemaV3, - getDrizzleTableName, -} from './schema-extraction.js' -import { type ComparisonOp, type EqualityOp, v3Dialect } from './sql-dialect.js' - -/** - * The client capability this factory consumes: `encryptQuery`, in both its - * single (`value, opts`) and batch (`terms[]`) forms. Declared structurally — - * with maximally-permissive operands — so it is satisfied by the nominal - * `EncryptionClient`, by the `TypedEncryptionClient` that `EncryptionV3` returns - * (whatever its schema tuple), AND by a hand-rolled test double, none needing a - * cast. Typing the parameter to the nominal `TypedEncryptionClient` would - * reject a client built for a narrower schema tuple (it accepts fewer tables than - * `readonly AnyV3Table[]`); the structural surface sidesteps that variance. - * - * Every operand is a QUERY TERM, not a storage envelope: `encryptQuery` mints a - * ciphertext-free term (no `c`) carrying all of the column's configured index - * terms, which the operator layer casts to the column's `eql_v3.query_` - * type. This reaches the bundle's `(domain, query_)` overloads and keeps - * WHERE-clause payloads free of the ciphertext a query never needs (#622). - * - * `never` operands: the real client's `encryptQuery` is generic (`queryType` is - * constrained to the column's own query types), which a concrete signature here - * cannot match. `never` params keep the structural surface satisfiable by that - * generic method AND by a test double; the call sites cast their real operands. - */ -type OperandEncryptionClient = { - encryptQuery( - value: never, - opts: never, - ): ChainableOperation - encryptQuery(terms: never): ChainableOperation -} - -// Path helpers now live in @cipherstash/stack/adapter-kit (shared with the -// Supabase adapter, #650); re-exported so existing imports keep working. -export { parseSelectorSegments, reconstructSelectorDocument } - -/** - * A dedicated error for v3 operator gating and operand-encryption failures, - * carrying the offending column/table/operator for diagnostics. - * - * INTENTIONAL FORK: this mirrors the v2 adapter's `EncryptionOperatorError` - * rather than sharing it. Unifying the two would couple `./drizzle` and - * `./eql/v3/drizzle` — two independently-versioned public entry points — so the - * duplication is deliberate, not an oversight. - */ -export class EncryptionOperatorError extends Error { - constructor( - message: string, - public readonly context?: { - columnName?: string - tableName?: string - operator?: string - }, - ) { - super(message) - this.name = 'EncryptionOperatorError' - } -} - -interface ColumnContext { - builder: AnyEncryptedV3Column - table: AnyV3Table - indexes: ColumnSchema['indexes'] - columnName: string - tableName: string - /** The `eql_v3.query_` type an operand for this column casts to, so - * `encryptQuery`'s ciphertext-free term reaches the narrowed-query overloads. - * `null` for storage-only columns (no query domain); those never encrypt an - * operand — every operator gates on a query capability first. JSON columns - * override this at the call site (`query_json`, an irregular name). */ - queryCast: string | null -} - -/** - * The `eql_v3.query_` cast for a column's storage domain — e.g. - * `public.eql_v3_text_search` → `eql_v3.query_text_search`. Uniform across the - * queryable column domains (`_eq`, `_ord`, `_ord_ore`, `_match`, `_search`); the - * two irregular cases are handled elsewhere: storage-only domains - * (`eql_v3_boolean`, the bare base types) have no query domain and return `null` - * (they are never queried), and `eql_v3_json_search` maps to `query_json`, cast - * explicitly on the JSON path. - */ -function queryCastForDomain(eqlType: string): string | null { - const bare = stripDomainSchema(eqlType) // public.eql_v3_text_search → eql_v3_text_search - const prefix = 'eql_v3_' - if (!bare.startsWith(prefix)) return null - const suffix = bare.slice(prefix.length) - // No index suffix (bare storage-only domain like `boolean`, `text`) → no query - // domain exists. These are gated out before any operand is encrypted. The - // suffixes match the column factories in `@/eql/v3/columns` exactly: ope - // ordering is the `_ord` domain (not `_ord_ope`) and text search is `_search` - // (there is no `_search_ore` column), so those two never occur here. - if (!/_(eq|ord|ord_ore|match|search)$/.test(suffix)) { - return null - } - return `eql_v3.query_${suffix}` -} - -export type EncryptionOperatorCallOpts = { - lockContext?: LockContext - audit?: AuditConfig -} - -/** - * An SDK encryption operation after its lock context has been applied: still - * auditable and awaitable, but not re-lockable. `withLockContext` returns this, - * not the full {@link ChainableOperation}, mirroring the real - * `EncryptOperationWithLockContext`, which drops `withLockContext` (you cannot - * lock-context twice). Modelling that is what lets the real client type satisfy - * the structural surface with no cast. - */ -type AuditableOperation = { - audit(config: AuditConfig): AuditableOperation - then: PromiseLike>['then'] -} - -/** - * The subset of an SDK encryption operation this factory drives: the fluent - * `withLockContext`/`audit` chain, and a `then` that resolves the operation's - * `Result`. Generic over the resolved payload `T` so the single `encryptQuery` - * carries an `EncryptedQueryResult` term and the batch form an - * `EncryptedQueryResult[]`, rather than the `unknown` this erased to before. - * - * Structural, not the concrete `EncryptQueryOperation` class, because the client - * is passed in and the factory must accept any implementation with this surface. - */ -type ChainableOperation = { - withLockContext(lockContext: LockContext): AuditableOperation - audit(config: AuditConfig): AuditableOperation - then: PromiseLike>['then'] -} - -/** - * Build v3-aware query operators (`eq`, `gte`, `matches`, `contains`, `asc`, …) bound to an - * encryption `client`. Each comparison/containment operator AUTO-ENCRYPTS its - * plaintext operand into an EQL v3 query term before handing it to Drizzle, so - * callers pass plaintext and the emitted SQL compares encrypted values. Every - * operator also gates on the target column's capabilities and throws - * {@link EncryptionOperatorError} when the column can't answer the operator - * (e.g. ordering a non-`ore` column). - * - * @param client - anything that can `encryptQuery` — the nominal - * `EncryptionClient` or the `TypedEncryptionClient` from `EncryptionV3` (no - * cast needed). - * @param defaults - lock context / audit applied to every operand encryption - * unless a per-call override is supplied. - * - * @example - * ```typescript - * const ops = createEncryptionOperatorsV3(await EncryptionV3({ schemas: [users] })) - * await db.select().from(users).where(await ops.eq(users.email, 'a@b.com')) - * ``` - */ -export function createEncryptionOperatorsV3( - client: OperandEncryptionClient, - defaults: EncryptionOperatorCallOpts = {}, -) { - const tableCache = new WeakMap() - // Per-column context memo. `resolveContext` is value-independent, so caching - // by column identity makes `inArray`/`notInArray` build the context (and its - // deep-cloned match block) once for the whole list instead of once per value. - const contextCache = new WeakMap() - - function drizzleTableOf(column: SQLWrapper): PgTable | undefined { - return is(column, Column) - ? (column.table as PgTable | undefined) - : undefined - } - - function resolveContext(column: SQLWrapper, operator: string): ColumnContext { - const cached = contextCache.get(column) - if (cached) return cached - - const columnName = is(column, Column) ? column.name : 'unknown' - const builder = getEqlV3Column(columnName, column) - if (!builder) { - throw new EncryptionOperatorError( - `Operator "${operator}" requires an encrypted v3 column, but "${columnName}" is not one.`, - { columnName, operator }, - ) - } - - const drizzleTable = drizzleTableOf(column) - const tableName = getDrizzleTableName(drizzleTable) ?? 'unknown' - - let table = drizzleTable ? tableCache.get(drizzleTable) : undefined - if (!table && drizzleTable) { - table = extractEncryptionSchemaV3(drizzleTable) - tableCache.set(drizzleTable, table) - } - if (!table) { - throw new EncryptionOperatorError( - `Unable to resolve the encrypted table for column "${columnName}".`, - { columnName, operator }, - ) - } - - const context: ColumnContext = { - builder, - table, - indexes: builder.build().indexes, - columnName, - tableName, - queryCast: queryCastForDomain(builder.getEqlType()), - } - contextCache.set(column, context) - return context - } - - /** - * Gate an operator on the column's indexes. `indexes` is a disjunction — any - * one of them grants the capability — so equality (`unique` OR `ore`) and the - * single-index gates share one rule and one diagnostic shape. - */ - function requireIndex( - ctx: ColumnContext, - indexes: readonly ('unique' | 'ore' | 'ope' | 'match' | 'ste_vec')[], - operator: string, - capability: string, - ): void { - if (!indexes.some((index) => ctx.indexes[index])) { - throw new EncryptionOperatorError( - `Operator "${operator}" requires ${capability} on column "${ctx.columnName}" (domain ${ctx.builder.getEqlType()} does not support it).`, - { columnName: ctx.columnName, tableName: ctx.tableName, operator }, - ) - } - } - - // Ordering flavour is pinned by the column's domain (eql-3.0.0): `_ord` - // domains carry `ope` (`op` CLLW-OPE term), `_ord_ore` domains carry `ore` - // (`ob` block-ORE term). Either satisfies the order/range operators, and an - // order-capable column answers equality via its ordering term too. - const EQUALITY_INDEXES = ['unique', 'ore', 'ope'] as const - const ORDERING_INDEXES = ['ore', 'ope'] as const - // Two DISTINCT operators, split by semantics (#617): - // - `matches` is bloom free-text (`match`, a `text_search`/`text_match` - // column): a one-sided, order- and multiplicity-insensitive token match that - // may false-positive. It emits `eql_v3.matches(col, operand)` (the SQL - // function keeps its bundle name) but is NOT containment. - // - `contains` is encrypted-JSONB containment (an `eql_v3_json_search` column, - // `ste_vec` index): exact jsonb `@>`, no false positives — genuine - // containment, so it keeps the `contains` name. - const MATCH_INDEXES = ['match'] as const - const JSON_CONTAINMENT_INDEXES = ['ste_vec'] as const - - function applyOperationOptions( - op: ChainableOperation, - opts?: EncryptionOperatorCallOpts, - ): AuditableOperation { - const lockContext = opts?.lockContext ?? defaults.lockContext - const audit = opts?.audit ?? defaults.audit - const withLock = lockContext ? op.withLockContext(lockContext) : op - if (audit) withLock.audit(audit) - return withLock - } - - function requireNonNullOperand( - ctx: ColumnContext, - value: unknown, - operator: string, - ): void { - if (value == null) { - throw new EncryptionOperatorError( - `Operator "${operator}" cannot encrypt a null operand for column "${ctx.columnName}". Use isNull() or isNotNull() for NULL checks.`, - { - columnName: ctx.columnName, - tableName: ctx.tableName, - operator, - }, - ) - } - } - - /** - * Reject a free-text needle the column's match index cannot answer. A needle - * shorter than the tokenizer's `token_length` yields an empty bloom filter, - * and `stored_bf @> '{}'` holds for every row — so without this the query - * silently returns the whole table. - */ - function requireAnswerableNeedle( - ctx: ColumnContext, - value: unknown, - operator: string, - ): void { - const match = ctx.indexes.match - if (!match) return - const reason = matchNeedleError(value, match) - if (reason) { - throw new EncryptionOperatorError( - `Operator "${operator}" cannot search column "${ctx.columnName}": ${reason}`, - { columnName: ctx.columnName, tableName: ctx.tableName, operator }, - ) - } - } - - function operandFailure( - ctx: ColumnContext, - operator: string, - reason: string, - ): EncryptionOperatorError { - return new EncryptionOperatorError( - `Failed to encrypt query operand for "${ctx.columnName}": ${reason}`, - { columnName: ctx.columnName, tableName: ctx.tableName, operator }, - ) - } - - /** - * Render a query term as a cast operand: `''::eql_v3.query_`. - * The cast is what reaches the bundle's `(domain, query_)` overloads — - * a bare `::jsonb` would hit the storage-domain overload, whose CHECK demands - * the ciphertext `c` a query term deliberately omits. `queryCast` is derived - * from the column's own domain (see `queryCastForDomain`), so `sql.raw` is safe. - */ - function castOperand( - ctx: ColumnContext, - operator: string, - term: EncryptedQueryResult, - ): SQL { - if (ctx.queryCast === null) { - throw operandFailure( - ctx, - operator, - `column domain "${ctx.builder.getEqlType()}" has no query operand type.`, - ) - } - return sql`${JSON.stringify(term)}::${sql.raw(ctx.queryCast)}` - } - - async function encryptOperand( - ctx: ColumnContext, - value: unknown, - operator: string, - queryType: QueryTypeName, - opts?: EncryptionOperatorCallOpts, - ): Promise { - requireNonNullOperand(ctx, value, operator) - - const result = await applyOperationOptions( - client.encryptQuery( - value as never, - { - table: ctx.table, - column: ctx.builder, - queryType, - } as never, - ), - opts, - ) - if (result.failure) { - throw operandFailure(ctx, operator, result.failure.message) - } - return castOperand(ctx, operator, result.data) - } - - /** - * Encrypt a whole operand list in ONE `encryptQuery` batch crossing (rather - * than one per value). The batch is position-stable, so the returned terms - * align index-for-index with `values`; a response of a different length means - * the contract was violated and is rejected rather than silently truncating - * the predicate (which would widen an `inArray` or narrow a `notInArray`). - * Null operands are rejected up front, so no term is filtered out of the batch. - */ - async function encryptOperands( - ctx: ColumnContext, - values: unknown[], - operator: string, - queryType: QueryTypeName, - opts?: EncryptionOperatorCallOpts, - ): Promise { - for (const value of values) requireNonNullOperand(ctx, value, operator) - - const terms = values.map((value) => ({ - value, - column: ctx.builder, - table: ctx.table, - queryType, - })) - const result = await applyOperationOptions( - client.encryptQuery(terms as never), - opts, - ) - if (result.failure) { - throw operandFailure(ctx, operator, result.failure.message) - } - - const encrypted = result.data as EncryptedQueryResult[] - if (encrypted.length !== values.length) { - throw operandFailure( - ctx, - operator, - `batch query encryption returned ${encrypted.length} terms for ${values.length} values.`, - ) - } - return encrypted.map((term) => castOperand(ctx, operator, term)) - } - - const colSql = (column: SQLWrapper): SQL => sql`${column}` - - async function equality( - op: EqualityOp, - left: SQLWrapper, - right: unknown, - opts?: EncryptionOperatorCallOpts, - ): Promise { - const ctx = resolveContext(left, op) - requireIndex(ctx, EQUALITY_INDEXES, op, 'equality') - const enc = await encryptOperand(ctx, right, op, 'equality', opts) - return v3Dialect.equality(op, colSql(left), enc) - } - - async function comparison( - op: ComparisonOp, - left: SQLWrapper, - right: unknown, - opts?: EncryptionOperatorCallOpts, - ): Promise { - const ctx = resolveContext(left, op) - requireIndex(ctx, ORDERING_INDEXES, op, 'order/range') - const enc = await encryptOperand(ctx, right, op, 'orderAndRange', opts) - return v3Dialect.comparison(op, colSql(left), enc) - } - - async function range( - left: SQLWrapper, - min: unknown, - max: unknown, - negate: boolean, - operator: string, - opts?: EncryptionOperatorCallOpts, - ): Promise { - const ctx = resolveContext(left, operator) - requireIndex(ctx, ORDERING_INDEXES, operator, 'order/range') - // Independent operands — encrypt concurrently rather than paying two - // sequential round-trips to the crypto backend. - const [encMin, encMax] = await Promise.all([ - encryptOperand(ctx, min, operator, 'orderAndRange', opts), - encryptOperand(ctx, max, operator, 'orderAndRange', opts), - ]) - // `v3Dialect.range` is already parenthesised, so `NOT` binds to the whole - // conjunction without a wrapper here. - const condition = v3Dialect.range(colSql(left), encMin, encMax) - return negate ? sql`NOT ${condition}` : condition - } - - /** - * Fuzzy free-text token match on a `text_search`/`text_match` column. NOT - * containment: it tests whether the needle's downcased 3-gram set is a subset - * of the haystack's, via a bloom filter — order- and multiplicity-insensitive - * and one-sided (a `true` may be a false positive, a `false` never is). Emits - * `eql_v3.matches(col, operand)` (the SQL function's bundle name). - */ - async function matches( - left: SQLWrapper, - right: unknown, - operator: string, - opts?: EncryptionOperatorCallOpts, - ): Promise { - const ctx = resolveContext(left, operator) - requireIndex(ctx, MATCH_INDEXES, operator, 'free-text search') - // The answerable-needle rule applies (a sub-`token_length` needle blooms to - // nothing and would match every row); the `query_` cast reaches the - // match overload. - requireAnswerableNeedle(ctx, right, operator) - const enc = await encryptOperand( - ctx, - right, - operator, - 'freeTextSearch', - opts, - ) - return v3Dialect.contains(colSql(left), enc) - } - - /** - * Exact encrypted-JSONB containment on an `eql_v3_json_search` (`ste_vec`) column: - * genuine jsonb `@>`, no false positives — hence it keeps the `contains` name. - * `eql_v3_json_search` has no `eql_v3.matches` overload; containment is the `@>` - * operator, whose `(eql_v3_json_search, eql_v3.query_json)` form takes a NARROWED - * query term (searchableJson → no ciphertext), cast to `eql_v3.query_json`. - */ - async function containsJsonOp( - left: SQLWrapper, - right: unknown, - operator: string, - opts?: EncryptionOperatorCallOpts, - ): Promise { - const ctx = resolveContext(left, operator) - requireIndex(ctx, JSON_CONTAINMENT_INDEXES, operator, 'JSON containment') - const needle = await encryptJsonContainmentTerm(ctx, right, operator, opts) - return v3Dialect.containsJson(colSql(left), needle) - } - - /** - * Build a `query_json` containment needle for a `json` column — the JSON query - * term carries no ciphertext and satisfies the `eql_v3.query_json` CHECK the - * `@>` overload needs. Cast here (not by `queryCastForDomain`): a json column's - * domain is `eql_v3_json_search` but its query operand type is the irregular - * `eql_v3.query_json`. - */ - async function encryptJsonContainmentTerm( - ctx: ColumnContext, - value: unknown, - operator: string, - opts?: EncryptionOperatorCallOpts, - ): Promise { - requireNonNullOperand(ctx, value, operator) - // Reject the empty-object needle. `doc @> '{}'` holds for EVERY document - // (jsonb `{} ⊆ anything`), so `contains(col, {})` would silently return the - // whole table — the same whole-table footgun the bloom path guards against - // with `requireAnswerableNeedle`. An accidental empty filter is a bug, not a - // match-all request; callers wanting every row should omit the predicate. - if ( - value !== null && - typeof value === 'object' && - !Array.isArray(value) && - Object.keys(value).length === 0 - ) { - throw new EncryptionOperatorError( - `Operator "${operator}" cannot take an empty object needle on column "${ctx.columnName}": it matches every row. Pass a non-empty sub-object, or omit the predicate to select all rows.`, - { columnName: ctx.columnName, tableName: ctx.tableName, operator }, - ) - } - const result = await applyOperationOptions( - client.encryptQuery( - value as never, - { - table: ctx.table, - column: ctx.builder, - queryType: 'searchableJson', - } as never, - ), - opts, - ) - if (result.failure) { - throw operandFailure(ctx, operator, result.failure.message) - } - return sql`${JSON.stringify(result.data)}::eql_v3.query_json` - } - - /** - * JSONPath selector-with-constraint on an `eql_v3_json_search` (`ste_vec`) - * column. Equality uses a value-selector containment needle, allowing the - * functional GIN index to answer the query. Ordering extracts the path entry - * with a selector hash and compares it to a ciphertext-free scalar ordering - * term. No storage ciphertext is placed in the WHERE clause. - */ - async function selectorCompare( - col: SQLWrapper, - path: string, - op: EqualityOp | ComparisonOp, - value: unknown, - operator: string, - opts?: EncryptionOperatorCallOpts, - ): Promise { - const ctx = resolveContext(col, operator) - requireIndex( - ctx, - JSON_CONTAINMENT_INDEXES, - operator, - 'JSON selector (searchableJson)', - ) - requireNonNullOperand(ctx, value, operator) - - // A selector compares a scalar leaf — reject non-scalars / non-orderable - // types up front with a clear error, not a deferred DB failure. - const ordering = op !== 'eq' && op !== 'ne' - const leafReason = unsupportedLeafReason(value, ordering) - if (leafReason) { - throw new EncryptionOperatorError( - `Operator "${operator}" cannot compare column "${ctx.columnName}": ${leafReason}`, - { columnName: ctx.columnName, tableName: ctx.tableName, operator }, - ) - } - - // Surface path-validation failures as EncryptionOperatorError with context. - let segments: string[] - try { - segments = parseSelectorSegments(path) - } catch (err) { - throw new EncryptionOperatorError( - `Operator "${operator}" on column "${ctx.columnName}": ${err instanceof Error ? err.message : String(err)}`, - { columnName: ctx.columnName, tableName: ctx.tableName, operator }, - ) - } - - const canonicalPath = jsonPathOf(segments) - - if (op === 'eq' || op === 'ne') { - const result = await applyOperationOptions( - client.encryptQuery( - { path: canonicalPath, value } as never, - { - table: ctx.table, - column: ctx.builder, - queryType: 'steVecValueSelector', - } as never, - ), - opts, - ) - if (result.failure) { - throw operandFailure(ctx, operator, result.failure.message) - } - const contains = v3Dialect.containsJson( - colSql(col), - sql`${JSON.stringify(result.data)}::eql_v3.query_json`, - ) - return op === 'eq' - ? contains - : sql`(NOT ${contains} OR ${colSql(col)} IS NULL)` - } - - // Selector hashing and scalar-term encryption are independent, so order - // them concurrently. `steVecTerm` accepts only the JSON-orderable scalar - // families (string and number), enforced above. - const [selResult, termResult] = await Promise.all([ - applyOperationOptions( - client.encryptQuery( - canonicalPath as never, - { - table: ctx.table, - column: ctx.builder, - queryType: 'steVecSelector', - } as never, - ), - opts, - ), - applyOperationOptions( - client.encryptQuery( - value as never, - { - table: ctx.table, - column: ctx.builder, - queryType: 'steVecTerm', - } as never, - ), - opts, - ), - ]) - if (selResult.failure) { - throw operandFailure(ctx, operator, selResult.failure.message) - } - if (termResult.failure) { - throw operandFailure(ctx, operator, termResult.failure.message) - } - - // A v3 selector term is the bare HMAC hash string; guard the shape so a - // wrapped envelope can't silently bind as a JSON blob and match no rows. - const selValue = selResult.data - if (typeof selValue !== 'string') { - throw operandFailure( - ctx, - operator, - `expected a bare selector hash, got ${typeof selValue}.`, - ) - } - - const selSql = sql`${selValue}::text` - const leftEntry = v3Dialect.selectorEntry(colSql(col), selSql) - const termCast = - typeof value === 'string' - ? 'eql_v3.query_text_ord' - : 'eql_v3.query_double_ord' - const rightTerm = sql`${JSON.stringify(termResult.data)}::${sql.raw(termCast)}` - return v3Dialect.comparison(op, leftEntry, rightTerm) - } - - /** Comparison methods bound to a `col->'path'` selector, mirroring the scalar - * operators. Async: each encrypts its operand. */ - function selectorOps(col: SQLWrapper, path: string) { - const at = - (op: EqualityOp | ComparisonOp) => - (value: unknown, opts?: EncryptionOperatorCallOpts) => - selectorCompare(col, path, op, value, `selector(${path}).${op}`, opts) - return { - /** `col->'path' = value` (encrypted equality at the selector). A row whose - * document lacks `path` is excluded (it is not equal to `value`). */ - eq: at('eq'), - /** `col->'path' <> value`, INCLUDING rows whose document lacks `path` - * ("not equal to value" covers "has no value"). */ - ne: at('ne'), - /** `col->'path' > value` (encrypted ordering at the selector). */ - gt: at('gt'), - /** `col->'path' >= value`. */ - gte: at('gte'), - /** `col->'path' < value`. */ - lt: at('lt'), - /** `col->'path' <= value`. */ - lte: at('lte'), - /** Order rows ascending by the encrypted scalar at `path`. Missing paths - * produce SQL NULL and follow PostgreSQL's normal NULL ordering. */ - asc: (opts?: EncryptionOperatorCallOpts) => - selectorOrder(col, path, 'asc', opts), - /** Order rows descending by the encrypted scalar at `path`. Missing paths - * produce SQL NULL and follow PostgreSQL's normal NULL ordering. */ - desc: (opts?: EncryptionOperatorCallOpts) => - selectorOrder(col, path, 'desc', opts), - } - } - - /** Build `ORDER BY eql_v3.ord_term(col -> selector::text)`. - * The selector is encrypted, but the extracted SteVec entry already carries - * its OPE ordering term, so no plaintext comparison operand is needed. */ - async function selectorOrder( - col: SQLWrapper, - path: string, - direction: 'asc' | 'desc', - opts?: EncryptionOperatorCallOpts, - ): Promise { - const operator = `selector(${path}).${direction}` - const ctx = resolveContext(col, operator) - requireIndex( - ctx, - JSON_CONTAINMENT_INDEXES, - operator, - 'JSON selector (searchableJson)', - ) - - let canonicalPath: string - try { - canonicalPath = jsonPathOf(parseSelectorSegments(path)) - } catch (err) { - throw new EncryptionOperatorError( - `Operator "${operator}" on column "${ctx.columnName}": ${err instanceof Error ? err.message : String(err)}`, - { columnName: ctx.columnName, tableName: ctx.tableName, operator }, - ) - } - - const result = await applyOperationOptions( - client.encryptQuery( - canonicalPath as never, - { - table: ctx.table, - column: ctx.builder, - queryType: 'steVecSelector', - } as never, - ), - opts, - ) - if (result.failure) { - throw operandFailure(ctx, operator, result.failure.message) - } - if (typeof result.data !== 'string') { - throw operandFailure( - ctx, - operator, - `expected a bare selector hash, got ${typeof result.data}.`, - ) - } - - const entry = v3Dialect.selectorEntry( - colSql(col), - sql`${result.data}::text`, - ) - const term = v3Dialect.orderBy(entry, 'ope') - return direction === 'asc' ? asc(term) : desc(term) - } - - async function inArrayOp( - left: SQLWrapper, - values: unknown[], - negate: boolean, - operator: string, - opts?: EncryptionOperatorCallOpts, - ): Promise { - const ctx = resolveContext(left, operator) - if (values.length === 0) { - throw new EncryptionOperatorError( - `Operator "${operator}" requires a non-empty list of values for column "${ctx.columnName}".`, - { columnName: ctx.columnName, tableName: ctx.tableName, operator }, - ) - } - // Gate and resolve the context once for the whole list, then encrypt it in - // a single `encryptQuery` batch crossing. - requireIndex(ctx, EQUALITY_INDEXES, operator, 'equality') - const op: EqualityOp = negate ? 'ne' : 'eq' - const encrypted = await encryptOperands( - ctx, - values, - operator, - 'equality', - opts, - ) - const conditions = encrypted.map((enc) => - v3Dialect.equality(op, colSql(left), enc), - ) - // The empty-list guard above leaves `conditions` non-empty, so `and`/`or` - // never return undefined here. - return (negate ? and(...conditions) : or(...conditions)) as SQL - } - - function orderTerm(column: SQLWrapper, operator: string): SQL { - const ctx = resolveContext(column, operator) - requireIndex(ctx, ORDERING_INDEXES, operator, 'order/range') - return v3Dialect.orderBy(colSql(column), ctx.indexes.ore ? 'ore' : 'ope') - } - - async function combine( - joiner: typeof and, - empty: SQL, - conditions: (SQL | SQLWrapper | Promise | undefined)[], - ): Promise { - const present = conditions.filter( - (c): c is SQL | SQLWrapper | Promise => c !== undefined, - ) - const resolved = await Promise.all(present) - return joiner(...resolved) ?? empty - } - - return { - /** Equality: `column = value`. Encrypts `r` and emits `eql_v3.eq`. - * Requires a `unique` or `ore` index on the column. */ - eq: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - equality('eq', l, r, opts), - /** Inequality: `column <> value`. Encrypts `r` and emits `eql_v3.neq`. - * Requires a `unique` or `ore` index on the column. */ - ne: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - equality('ne', l, r, opts), - /** Greater-than: `column > value`. Encrypts `r` and emits `eql_v3.gt`. - * Requires an `ore` (order/range) index. */ - gt: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - comparison('gt', l, r, opts), - /** Greater-than-or-equal: `column >= value`. Encrypts `r` and emits - * `eql_v3.gte`. Requires an `ore` (order/range) index. */ - gte: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - comparison('gte', l, r, opts), - /** Less-than: `column < value`. Encrypts `r` and emits `eql_v3.lt`. - * Requires an `ore` (order/range) index. */ - lt: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - comparison('lt', l, r, opts), - /** Less-than-or-equal: `column <= value`. Encrypts `r` and emits - * `eql_v3.lte`. Requires an `ore` (order/range) index. */ - lte: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - comparison('lte', l, r, opts), - /** Inclusive range `min <= column <= max`. Encrypts both bounds - * concurrently. Requires an `ore` (order/range) index. */ - between: ( - l: SQLWrapper, - min: unknown, - max: unknown, - opts?: EncryptionOperatorCallOpts, - ) => range(l, min, max, false, 'between', opts), - /** Negated inclusive range `NOT (min <= column <= max)`. Encrypts both - * bounds concurrently. Requires an `ore` (order/range) index. */ - notBetween: ( - l: SQLWrapper, - min: unknown, - max: unknown, - opts?: EncryptionOperatorCallOpts, - ) => range(l, min, max, true, 'notBetween', opts), - /** Fuzzy free-text token match — the needle's 3-gram set is (bloom-)tested - * as a subset of the column's. NOT containment: order/multiplicity- - * insensitive and one-sided (a `true` may be a false positive). Encrypts - * `r`. Requires a `match` (free-text search) index. */ - matches: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - matches(l, r, 'matches', opts), - /** Exact encrypted-JSONB containment (`@>`): matches rows whose document - * contains the given sub-object. No false positives. Encrypts `r`. Requires - * a `ste_vec` index (a `types.Json` column). */ - contains: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - containsJsonOp(l, r, 'contains', opts), - /** JSONPath selector-with-constraint on a `types.Json` (`ste_vec`) column. - * Returns comparison and ordering methods bound to `col->'path'` — e.g. - * `await ops.selector(users.doc, '$.age').gt(21)` emits - * `col->'' > `, while `.asc()` emits - * `ORDER BY eql_v3.ord_term(col->'')`. Its unique power over `contains` - * is ordering at a path (`gt`/`gte`/`lt`/`lte` and `asc`/`desc`); `eq`/`ne` - * are also provided. Dot-notation object paths only in v1. */ - selector: (l: SQLWrapper, path: string) => selectorOps(l, path), - /** Membership: ORs one encrypted `eq` term per value. The whole list is - * encrypted in one `encryptQuery` batch crossing. Rejects an empty list; - * requires a `unique` or `ore` index. */ - inArray: ( - l: SQLWrapper, - values: unknown[], - opts?: EncryptionOperatorCallOpts, - ) => inArrayOp(l, values, false, 'inArray', opts), - /** Non-membership: ANDs one encrypted `ne` term per value. The whole list - * is encrypted in one `encryptQuery` batch crossing. Rejects an empty list; - * requires a `unique` or `ore` index. */ - notInArray: ( - l: SQLWrapper, - values: unknown[], - opts?: EncryptionOperatorCallOpts, - ) => inArrayOp(l, values, true, 'notInArray', opts), - /** Ascending order by the encrypted order term (`eql_v3.ord_term` / - * `eql_v3.ord_term_ore`, by the column's ordering flavour). - * Synchronous (no operand to encrypt). Requires an ordering index. */ - asc: (c: SQLWrapper) => asc(orderTerm(c, 'asc')), - /** Descending order by the encrypted order term (`eql_v3.ord_term` / - * `eql_v3.ord_term_ore`, by the column's ordering flavour). - * Synchronous (no operand to encrypt). Requires an ordering index. */ - desc: (c: SQLWrapper) => desc(orderTerm(c, 'desc')), - /** Conjunction of the given conditions, awaiting any async operands and - * dropping `undefined`. Empty input resolves to `true`. */ - and: (...conds: (SQL | SQLWrapper | Promise | undefined)[]) => - combine(and, sql`true`, conds), - /** Disjunction of the given conditions, awaiting any async operands and - * dropping `undefined`. Empty input resolves to `false`. */ - or: (...conds: (SQL | SQLWrapper | Promise | undefined)[]) => - combine(or, sql`false`, conds), - /** Drizzle's `isNull`, re-exported unchanged — `column IS NULL` needs no - * encryption and works on any (nullable) encrypted column. */ - isNull, - /** Drizzle's `isNotNull`, re-exported unchanged — `column IS NOT NULL` - * needs no encryption. */ - isNotNull, - /** Drizzle's `not`, re-exported unchanged — negates an already-built - * (encrypted) predicate. Safe over any operator here, including `between`, - * whose fragment is self-parenthesising. */ - not, - /** Drizzle's `exists`, re-exported unchanged — for correlated subqueries. */ - exists, - /** Drizzle's `notExists`, re-exported unchanged — for correlated - * subqueries. */ - notExists, - } -} diff --git a/packages/stack-drizzle/src/v3/schema-extraction.ts b/packages/stack-drizzle/src/v3/schema-extraction.ts deleted file mode 100644 index 68f8796bb..000000000 --- a/packages/stack-drizzle/src/v3/schema-extraction.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { - type AnyEncryptedV3Column, - type AnyV3Table, - encryptedTable, -} from '@cipherstash/stack/eql/v3' -import type { PgTable } from 'drizzle-orm/pg-core' -import { getEqlV3Column } from './column.js' - -/** Drizzle stashes the SQL table name on this well-known symbol key. */ -const DRIZZLE_NAME = Symbol.for('drizzle:Name') - -/** - * Read the SQL table name Drizzle stashes on a `pgTable`. Returns `undefined` - * for a non-object or a table not built with `pgTable()`. Shared by - * {@link extractEncryptionSchemaV3} and the operator factory so the - * symbol-key introspection lives in exactly one place. - */ -export function getDrizzleTableName(table: unknown): string | undefined { - if (!table || typeof table !== 'object') return undefined - const name = (table as Record)[DRIZZLE_NAME] - return typeof name === 'string' ? name : undefined -} - -export function extractEncryptionSchemaV3(table: PgTable): AnyV3Table { - const tableName = getDrizzleTableName(table) - if (!tableName) { - throw new Error( - 'Unable to read table name from Drizzle table. Use a table created with pgTable().', - ) - } - - const columns: Record = {} - for (const [property, column] of Object.entries(table)) { - if (typeof column !== 'object' || column === null) continue - const columnName = - 'name' in column && typeof column.name === 'string' - ? column.name - : property - const builder = getEqlV3Column(columnName, column) - if (builder) columns[property] = builder - } - - if (Object.keys(columns).length === 0) { - throw new Error( - `No encrypted v3 columns found in table "${tableName}". Declare columns with the v3 drizzle \`types\` namespace.`, - ) - } - - return encryptedTable(tableName, columns) -} diff --git a/packages/stack-drizzle/tsconfig.typecheck.json b/packages/stack-drizzle/tsconfig.typecheck.json index 9d06679f0..16d312f2f 100644 --- a/packages/stack-drizzle/tsconfig.typecheck.json +++ b/packages/stack-drizzle/tsconfig.typecheck.json @@ -1,4 +1,17 @@ { + // The typecheck gate CI runs (`test:types` -> vitest typecheck) covers the + // `.test-d.ts` files plus the two `integration/**` adapters that #772's + // review found were erroring in a file nothing compiled. + // + // `src/`, the runtime `__tests__/*.test.ts` and the rest of `integration/**` + // are still ungated: 63 errors under the full `tsconfig.json`, most of them + // one root cause — `V3_MATRIX`'s `indexes` union in `@cipherstash/test-kit` + // (#778). Widen this `include` as that count comes down; do not widen it to + // the whole project until it is zero, or the gate is useless. "extends": "./tsconfig.json", - "include": ["__tests__/**/*.test-d.ts"] + "include": [ + "__tests__/**/*.test-d.ts", + "integration/adapter.ts", + "integration/json-adapter.ts" + ] } diff --git a/packages/stack-drizzle/tsup.config.ts b/packages/stack-drizzle/tsup.config.ts index 19ce5967e..dcffd3486 100644 --- a/packages/stack-drizzle/tsup.config.ts +++ b/packages/stack-drizzle/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: ['src/index.ts', 'src/v3/index.ts'], + entry: ['src/index.ts'], outDir: 'dist', format: ['cjs', 'esm'], sourcemap: true, diff --git a/packages/stack-supabase/README.md b/packages/stack-supabase/README.md index 746cfc902..70aaf1981 100644 --- a/packages/stack-supabase/README.md +++ b/packages/stack-supabase/README.md @@ -9,9 +9,9 @@ Depends on `@cipherstash/stack`; install both: npm install @cipherstash/stack @cipherstash/stack-supabase @supabase/supabase-js ``` -## EQL v3 (recommended) +## EQL v3 -`encryptedSupabaseV3` introspects the database at connect time (native +`encryptedSupabase` introspects the database at connect time (native `public.eql_v3_*` column domains) — no schema argument, `select('*')` support, equality/range filters, and encrypted `order()` on OPE columns. @@ -22,18 +22,23 @@ Use the Drizzle or Prisma Next adapter, or a carefully scoped direct SQL/RPC path. ```ts -import { encryptedSupabaseV3 } from '@cipherstash/stack-supabase' +import { encryptedSupabase } from '@cipherstash/stack-supabase' -const es = await encryptedSupabaseV3(supabaseUrl, supabaseKey) +const es = await encryptedSupabase(supabaseUrl, supabaseKey) await es.from('users').select('id, email').eq('email', 'a@b.com') ``` +`encryptedSupabaseV3` remains as a `@deprecated`, type-identical alias of +`encryptedSupabase`, so existing imports keep working. + Introspection needs a direct Postgres connection (`DATABASE_URL`), so `pg` is an optional peer and the factory cannot run in a Worker or the browser. -## EQL v2 (legacy) +## EQL v2 (removed) -`encryptedSupabase` wraps a supabase-js client with a v2 schema; still shipped for -existing v2 deployments. +The legacy EQL v2 authoring wrapper — `encryptedSupabase({ encryptionClient, +supabaseClient }).from(tableName, schema)` — has been removed; this package now +authors and queries EQL v3 only. Migrate existing v2 columns to an `eql_v3_*` +domain, or pin the last release that shipped the v2 wrapper. See the `stash-supabase` agent skill and https://cipherstash.com/docs for the full guide. diff --git a/packages/stack-supabase/__tests__/column-map-predicate.test.ts b/packages/stack-supabase/__tests__/column-map-predicate.test.ts new file mode 100644 index 000000000..2575e993a --- /dev/null +++ b/packages/stack-supabase/__tests__/column-map-predicate.test.ts @@ -0,0 +1,133 @@ +import { encryptedColumn } from '@cipherstash/stack/schema' +import { describe, expect, it } from 'vitest' +import { isV3ColumnLike } from '../src/column-map' + +/** + * Unit coverage for the structural v3 gate. + * + * `ColumnMap`'s tests pin the CONSEQUENCE — construction refuses, and no query + * string reaches PostgREST. They do not pin the predicate's shape: every fixture + * that reaches it either satisfies all four probes or (the one negative case) + * misses two at once, so no test there distinguishes a four-probe gate from a + * three- or two-probe one. Deleting any single probe, or weakening any single + * `typeof` to `true`, left the whole package suite green. + * + * These cases are written to be one-probe-apart from a passing builder, so each + * fails if and only if its own probe is removed. + */ + +type Probe = 'getName' | 'getEqlType' | 'getQueryCapabilities' | 'build' + +const PROBES: Probe[] = [ + 'getName', + 'getEqlType', + 'getQueryCapabilities', + 'build', +] + +/** A builder presenting exactly the surface the predicate probes for. */ +const conforming = (): Record => ({ + getName: () => 'email', + getEqlType: () => 'public.eql_v3_text_search', + getQueryCapabilities: () => ({ + equality: true, + orderAndRange: false, + freeTextSearch: true, + }), + build: () => ({}), +}) + +const withoutProbe = (probe: Probe): Record => { + const builder = conforming() + delete builder[probe] + return builder +} + +const withNonFunctionProbe = (probe: Probe): Record => ({ + ...conforming(), + [probe]: 'not a function', +}) + +describe('isV3ColumnLike', () => { + it('accepts a builder presenting all four members', () => { + expect(isV3ColumnLike(conforming())).toBe(true) + }) + + // The mutation-killing core. Each fixture is one member away from the + // conforming builder above, so it can only be rejected by that member's own + // probe. A fixture missing two members (the shape the ColumnMap tests use) + // would be rejected by either, and so kills neither. + it.each(PROBES)('rejects a builder missing only %s()', (probe) => { + expect(isV3ColumnLike(withoutProbe(probe))).toBe(false) + }) + + // `'x' in builder` alone is satisfied by any present key. These pin the + // `typeof … === 'function'` half of each conjunct, which the `in` half cannot + // stand in for. + it.each(PROBES)('rejects a builder whose %s is not a function', (probe) => { + expect(isV3ColumnLike(withNonFunctionProbe(probe))).toBe(false) + }) + + // `typeof null === 'object'`, so `null` slips past the first half of the + // guard and only the explicit `builder === null` half rejects it. That half + // is load-bearing on its own: without it `'getName' in null` throws a raw + // TypeError instead of returning false, and ColumnMap's fail-closed error is + // replaced by an unrelated crash. + it('rejects null', () => { + expect(isV3ColumnLike(null)).toBe(false) + }) + + // These four go the other way — each is rejected by the `typeof builder !== + // 'object'` half. Split from `null` above so a mutant that drops only one + // half of the guard names which half it dropped. + it.each([ + ['undefined', undefined], + ['a number', 42], + ['a string', 'email'], + ['a boolean', true], + ])('rejects %s', (_label, builder) => { + expect(isV3ColumnLike(builder)).toBe(false) + }) + + // The real `Encrypted*Column` classes carry all four methods on the + // PROTOTYPE — their own properties are just `columnName`/`definition`. So the + // probes must use `in`, which walks the chain, and must not become + // `Object.hasOwn`, which does not. + it('accepts a builder whose members live on the prototype', () => { + class PrototypeColumn { + getName() { + return 'email' + } + getEqlType() { + return 'public.eql_v3_text_search' + } + getQueryCapabilities() { + return { equality: true, orderAndRange: false, freeTextSearch: true } + } + build() { + return {} + } + } + + const builder = new PrototypeColumn() + + expect(Object.hasOwn(builder, 'getName')).toBe(false) + expect(isV3ColumnLike(builder)).toBe(true) + }) + + // The case the four-probe design exists for, stated with the real class + // rather than a hand-rolled stub: a v2 column builder genuinely has `build()` + // and `getName()` and genuinely lacks the other two. A two-probe gate would + // accept it and its filter operands would go to PostgREST in the clear. + it('rejects a real EQL v2 column builder', () => { + const v2 = encryptedColumn('email').equality() + + // Spelled out so that if `EncryptedColumn` ever grows one of these, the + // failure names the cause rather than just reporting `true !== false`. + expect(typeof v2.getName).toBe('function') + expect(typeof v2.build).toBe('function') + expect('getEqlType' in v2).toBe(false) + expect('getQueryCapabilities' in v2).toBe(false) + expect(isV3ColumnLike(v2)).toBe(false) + }) +}) diff --git a/packages/stack-supabase/__tests__/helpers/supabase-mock.ts b/packages/stack-supabase/__tests__/helpers/supabase-mock.ts index 26d8576a4..866a93283 100644 --- a/packages/stack-supabase/__tests__/helpers/supabase-mock.ts +++ b/packages/stack-supabase/__tests__/helpers/supabase-mock.ts @@ -212,3 +212,43 @@ export function createMockSupabase(resultData: unknown = []) { return { client, calls, callsFor } } + +/** + * A table whose column builders are structurally EQL v3 but are NOT instances + * of the `EncryptedV3Column` this package imports — which is exactly how a + * table authored from `@cipherstash/stack/wasm-inline` presents, because tsup + * emits that class twice (see `isV3ColumnLike` in `src/column-map.ts`). + * + * Object literals, not the real classes: reproducing the split with the real + * ones needs a built `dist/`, and `vitest.shared.ts:4-14` keeps `pnpm test` + * free of that. The dist-level version lives in the portable-entry plan. + */ +export function wasmAuthoredV3Table(tableName: string, columnNames: string[]) { + const columnBuilders = Object.fromEntries( + columnNames.map((name) => [ + name, + { + getName: () => name, + getEqlType: () => 'public.eql_v3_text_eq', + getQueryCapabilities: () => ({ + equality: true, + orderAndRange: false, + freeTextSearch: false, + }), + build: () => ({ cast_as: 'text', indexes: {} }), + }, + ]), + ) + return { + tableName, + columnBuilders, + buildColumnKeyMap: () => + Object.fromEntries(columnNames.map((name) => [name, name])), + build: () => ({ + tableName, + columns: Object.fromEntries( + columnNames.map((name) => [name, columnBuilders[name].build()]), + ), + }), + } +} diff --git a/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts b/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts index 5154b8c1e..b9acdd008 100644 --- a/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts +++ b/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts @@ -1,13 +1,8 @@ import type { EncryptionClient } from '@cipherstash/stack/encryption' import { encryptedTable, types } from '@cipherstash/stack/eql/v3' import { EncryptionErrorTypes } from '@cipherstash/stack/errors' -import { - encryptedColumn, - encryptedTable as encryptedTableV2, -} from '@cipherstash/stack/schema' import { describe, expect, it } from 'vitest' import { EncryptedQueryBuilderImpl } from '../src/query-builder' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' import { createMockEncryptionClient, createMockSupabase, @@ -18,18 +13,14 @@ import { /** * Regression coverage for #626: the query builder's catch block used to hardcode * `encryptionError: undefined`, so the typed `EncryptedSupabaseError.encryptionError` - * field was dead. The v2 tests pin that a genuine encryption failure now threads its - * `EncryptionError` through the shared base `execute()` catch, while a plain - * (non-encryption) throw leaves it unset. The v3 tests cover the dialect's own - * `encryptionFailure` path, which synthesizes an `EncryptionError` for its two - * query-term contract-violation cases (length mismatch, null envelope) that have - * no operation failure to wrap. + * field was dead. The `execute()` tests pin that a genuine encryption failure now + * threads its `EncryptionError` through the shared `execute()` catch, while a plain + * (non-encryption) throw leaves it unset. The `encryptionFailure` tests cover the + * dialect's own `encryptionFailure` path, which synthesizes an `EncryptionError` + * for its two query-term contract-violation cases (length mismatch, null envelope) + * that have no operation failure to wrap. */ -const usersV2 = encryptedTableV2('users', { - email: encryptedColumn('email').freeTextSearch().equality(), -}) - const usersV3 = encryptedTable('users', { email: types.TextEq('email'), }) @@ -48,7 +39,7 @@ function failingOperation(failure: { type: string; message: string }) { } describe('EncryptedSupabaseError.encryptionError (#626)', () => { - it('v2: threads the EncryptionError through on an encryption failure', async () => { + it('threads the EncryptionError through on an encryption failure', async () => { const failure = { type: EncryptionErrorTypes.EncryptionError, message: 'zerokms unreachable', @@ -62,7 +53,7 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => { const { client: supabase } = createMockSupabase() const builder = new EncryptedQueryBuilderImpl( 'users', - usersV2, + usersV3, encryptionClient as unknown as EncryptionClient, supabase, ) @@ -77,7 +68,7 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => { ) }) - it('v2: leaves encryptionError unset on a plain (non-encryption) error', async () => { + it('leaves encryptionError unset on a plain (non-encryption) error', async () => { const encryptionClient = createMockEncryptionClient() const { client: supabase } = createMockSupabase() // Make the underlying supabase call throw a non-encryption error: an insert @@ -88,7 +79,7 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => { const builder = new EncryptedQueryBuilderImpl( 'users', - usersV2, + usersV3, encryptionClient, supabase, ) @@ -105,8 +96,8 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => { // wrap for its contract-violation cases, so it synthesizes an EncryptionError. // Drive it directly: a two-element `in` list whose bulkEncrypt returns one // term trips the length-mismatch check. (The base `execute()` threading is - // already covered by the v2 test above; overriding `encryptModel` here would - // only re-run that shared path, not this v3-specific branch.) + // already covered by the `execute()` tests above; overriding `encryptModel` + // here would only re-run that shared path, not this v3-specific branch.) const encryptionClient = createMockEncryptionClient() as unknown as { bulkEncrypt: (...args: unknown[]) => unknown } @@ -114,7 +105,7 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => { operation([{ data: fakeEnvelope('ada', 'email') }]) const { client: supabase } = createMockSupabase() - const { error, status } = await new EncryptedQueryBuilderV3Impl( + const { error, status } = await new EncryptedQueryBuilderImpl( 'users', usersV3, encryptionClient as unknown as EncryptionClient, @@ -144,7 +135,7 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => { operation([{ data: null }, { data: fakeEnvelope('grace', 'email') }]) const { client: supabase } = createMockSupabase() - const { error, status } = await new EncryptedQueryBuilderV3Impl( + const { error, status } = await new EncryptedQueryBuilderImpl( 'users', usersV3, encryptionClient as unknown as EncryptionClient, diff --git a/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts b/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts index caa0192b6..185700364 100644 --- a/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts +++ b/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts @@ -1,8 +1,14 @@ import { encryptedTable, types } from '@cipherstash/stack/eql/v3' +import { + encryptedColumn, + encryptedTable as v2EncryptedTable, +} from '@cipherstash/stack/schema' import { describe, expect, it } from 'vitest' +import { ColumnMap } from '../src/column-map' import type { IntrospectionResult } from '../src/introspect' import { groupUnmodelledRows } from '../src/introspect' import { mergeDeclaredTables, synthesizeTables } from '../src/schema-builder' +import { wasmAuthoredV3Table } from './helpers/supabase-mock' const introspection: IntrospectionResult = [ { @@ -242,3 +248,101 @@ describe('groupUnmodelledRows', () => { expect(groupUnmodelledRows([]).size).toBe(0) }) }) + +describe('ColumnMap recognises v3 columns structurally, not by class identity', () => { + // tsup emits `EncryptedV3Column` TWICE — once into the chunk + // `dist/adapter-kit.js` imports, once inline in `dist/wasm-inline.js` (a + // separate esbuild run). A table authored from `@cipherstash/stack/wasm-inline` + // therefore failed `builder instanceof EncryptedV3Column` for EVERY column, + // leaving `v3Columns` empty — so the filter collector skipped every term and + // the RAW PLAINTEXT operand went into the PostgREST query string, while + // `::jsonb` casts and decryption kept working. + // + // These two assert the MECHANISM (`v3Columns` is populated / not + // over-populated). The HARM — what PostgREST actually receives — is asserted + // in `supabase-v3-wire.test.ts` by Step 2, because a check that merely + // probed `getName` would satisfy the two below. + it('accepts a builder that merely has the v3 column surface', () => { + const table = wasmAuthoredV3Table('users', ['email']) + + const columns = new ColumnMap('users', table as never, null) + + expect(columns.isEncryptedV3Column('email')).toBe(true) + expect(columns.encryptedColumnNames).toContain('email') + }) + + it('throws on a builder missing the v3 column surface', () => { + // v2 columns have `build()` and `getName()` (`EncryptedColumn`, + // `packages/stack/src/schema/index.ts:442,449`) but neither `getEqlType()` + // nor `getQueryCapabilities()` (`eql/v3/columns.ts:445,450`). Four probes, + // not two, is what keeps the predicate honest. + // + // `columnBuilders` on an `AnyV3Table` must hold ONLY encrypted v3 columns, + // so a builder that fails the probe is malformed input. Silently skipping it + // is not a safe default: the column would drop out of `v3Columns` and its + // filter operands would go to PostgREST as PLAINTEXT. Fail closed at + // construction instead. + const v2 = { getName: () => 'email', build: () => ({}) } + const table = { + tableName: 'users', + columnBuilders: { email: v2 }, + buildColumnKeyMap: () => ({ email: 'email' }), + build: () => ({ tableName: 'users', columns: {} }), + } + + // Pin the SPECIFIC message, not just the `[supabase v3]` prefix: 32 errors + // across this package share that prefix, two of them thrown by `ColumnMap` + // itself. A prefix-only matcher stays green whenever a DIFFERENT one of + // those fires first — measured: with `assertNoPropertyDbNameCollision` + // throwing unconditionally, so the fail-closed probe below is never + // reached, this test still passed. It could not tell which error it caught. + // (Deleting the probe outright does turn it red — construction then + // succeeds and nothing throws. It is the bypass, not the deletion, that the + // loose matcher was blind to.) + expect(() => new ColumnMap('users', table as never, null)).toThrow( + /\[supabase v3\]: column "email" on table "users" is not a recognised EQL v3 column builder/, + ) + }) + + it('throws a diagnosis, not a raw TypeError, on a whole v2 table', () => { + // A v2 `EncryptedTable` is structurally identical to a v3 one at the table + // level — same `tableName`, same `columnBuilders`. The only discriminator + // is `buildColumnKeyMap()`, which the constructor calls UNGUARDED as its + // first statement, so a v2 table died with `table.buildColumnKeyMap is not + // a function` — naming an internal method, not the version mismatch that + // caused it. + // + // The column-level probe above cannot catch this: it runs later, and by + // then the constructor has already crashed. + const v2Table = v2EncryptedTable('users', { + email: encryptedColumn('email').equality(), + }) + + expect(() => new ColumnMap('users', v2Table as never, null)).toThrow( + /\[supabase v3\]: table "users" is an EQL v2 table/, + ) + }) +}) + +describe('every types.* domain satisfies the structural v3 probe', () => { + // Stated through the public consequence rather than against the predicate: + // whatever the catalog grows to, ColumnMap must see the column as encrypted. + // A domain whose builder lost one of the four methods would be silently + // treated as PLAINTEXT — the PF2 failure again, from a different direction. + // Enumerated, not hardcoded (40 domains today), so a new one is covered the + // day it is added. The predicate's own shape is pinned separately, one probe + // at a time, in `column-map-predicate.test.ts`. + it('recognises a column built by any factory in the catalog', () => { + // Deterministic iteration over the WHOLE catalog, not a probabilistic + // sample: the guarantee this pins is "every domain", so every domain must + // actually run. `fc.constantFrom` would leave that to chance across its + // default run count. + for (const domain of Object.keys(types) as (keyof typeof types)[]) { + const table = encryptedTable('t', { c: types[domain]('c') }) + + const columns = new ColumnMap('t', table as never, null) + + expect(columns.isEncryptedV3Column('c')).toBe(true) + } + }) +}) diff --git a/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts b/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts index b510a9baa..ce9835a2f 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts @@ -1,11 +1,8 @@ import { encryptedTable, types } from '@cipherstash/stack/eql/v3' -import { - encryptedColumn, - encryptedTable as encryptedTableV2, -} from '@cipherstash/stack/schema' import { describe, expect, it, vi } from 'vitest' -import { encryptedSupabase } from '../src/index.js' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' +import { ColumnMap } from '../src/column-map' +import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder' +import { assertTermQueryable } from '../src/query-encrypt' import { createMockEncryptionClient, createMockSupabase, @@ -29,11 +26,6 @@ const users = encryptedTable('users', { bio: types.TextMatch('bio'), }) -const usersV2 = encryptedTableV2('users', { - email: encryptedColumn('email').freeTextSearch().equality(), - age: encryptedColumn('age').dataType('number').equality().orderAndRange(), -}) - // DB column names as introspection would report them (id/note are plaintext). const USERS_ALL_COLUMNS = [ 'id', @@ -957,11 +949,10 @@ describe('encryptedSupabaseV3 wire encoding', () => { expect(error).toBeNull() expect(supabase.callsFor('single')).toHaveLength(1) expect(Array.isArray(data)).toBe(false) - // `data` is declared `T[] | null` on the shared builder surface; single() - // narrows it to one row at runtime only. - const single = data as unknown as { email: string; createdAt: Date } - expect(single.email).toBe('a@b.com') - expect(single.createdAt).toBeInstanceOf(Date) + // `single()` narrows the awaited shape to ONE row at the type level too, + // so `data` is the row itself — no cast needed to reach its fields. + expect(data?.email).toBe('a@b.com') + expect(data?.createdAt).toBeInstanceOf(Date) }) it('maybeSingle() returns null for null result data without throwing', async () => { @@ -1176,286 +1167,6 @@ describe('encryptedSupabaseV3 wire encoding', () => { }) }) -// --------------------------------------------------------------------------- -// v2 regression — the dialect seams must leave the v2 wire encoding untouched -// --------------------------------------------------------------------------- - -describe('encryptedSupabase (v2) wire encoding is unchanged by the dialect seams', () => { - function v2Instance(resultData: unknown = []) { - const supabase = createMockSupabase(resultData) - const es = encryptedSupabase({ - encryptionClient: createMockEncryptionClient(), - supabaseClient: supabase.client, - }) - return { es, supabase } - } - - it('wraps encrypted mutation values in the { data } composite shape', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).insert({ email: 'a@b.com', note: 'x' }) - - const [insert] = supabase.callsFor('insert') - const body = insert.args[0] as Record - expect(body.email).toHaveProperty('data') - expect(isFakeEnvelope((body.email as Record).data)).toBe( - true, - ) - expect(body.note).toBe('x') - }) - - it('encodes filter terms as composite literals via encryptQuery', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id, email').eq('email', 'a@b.com') - - const [eq] = supabase.callsFor('eq') - expect(eq.args).toEqual(['email', '("a@b.com")']) - }) - - it('keeps like on encrypted columns as like', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id, email').like('email', 'a@b') - - expect(supabase.callsFor('like')).toHaveLength(1) - expect(supabase.callsFor('filter')).toHaveLength(0) - }) - - it('adds plain ::jsonb casts without aliasing', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id, email, age') - - const [select] = supabase.callsFor('select') - expect(select.args[0]).toBe('id, email::jsonb, age::jsonb') - }) - - it('passes order() column names through unchanged', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id, age').order('age') - - const [order] = supabase.callsFor('order') - expect(order.args[0]).toBe('age') - }) - - it('passes the onConflict option through by reference', async () => { - const { es, supabase } = v2Instance() - - const options = { onConflict: 'email' } - await es.from('users', usersV2).upsert({ email: 'a@b.com' }, options) - - const [upsert] = supabase.callsFor('upsert') - expect(upsert.args[1]).toBe(options) - }) - - it('passes an all-plaintext or() string through verbatim', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id').or('id.eq.1,note.eq.x') - - const [or] = supabase.callsFor('or') - expect(or.args[0]).toBe('id.eq.1,note.eq.x') - }) - - // `contains` is not a PostgREST operator in EITHER dialect — the structured - // or() path emitted `note.contains.x` on v2 too, since the base builder never - // translated the token. Fixed in `rebuildOrString`, so both dialects inherit it. - it('translates a structured or() contains to cs', async () => { - const { es, supabase } = v2Instance() - - await es - .from('users', usersV2) - .select('id') - .or([{ column: 'note', op: 'contains', value: 'x' }]) - - expect(supabase.callsFor('or')[0].args[0]).toBe('note.cs.x') - }) - - // ------------------------------------------------------------------------- - // Characterization tests for the paths `toDbSpace()` will rewrite. Each pins - // the correlation between the term collector (`encryptFilterValues`) and the - // applier (`applyFilters`), which agree only by array index / column name. - // ------------------------------------------------------------------------- - - it('match() encrypts encrypted keys and passes plaintext through', async () => { - const { es, supabase } = v2Instance() - - await es - .from('users', usersV2) - .select('id') - .match({ email: 'a@b.com', note: 'plain' }) - - const [match] = supabase.callsFor('match') - const query = match.args[0] as Record - expect(query.email).toBe('("a@b.com")') - expect(query.note).toBe('plain') - // Key order survives the Record -> entries -> Record round-trip - expect(Object.keys(query)).toEqual(['email', 'note']) - }) - - it('not() encrypts on encrypted columns and passes plaintext through', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id').not('email', 'eq', 'a@b.com') - await es.from('users', usersV2).select('id').not('note', 'eq', 'plain') - - const [encrypted, plain] = supabase.callsFor('not') - expect(encrypted.args).toEqual(['email', 'eq', '("a@b.com")']) - expect(plain.args).toEqual(['note', 'eq', 'plain']) - }) - - // The v2 composite literal `("a@b.com")` is itself quote-bearing, so it needs - // the same escaped operand as v3 — postgrest-js's `in()` would emit - // `in.("("a@b.com")")` and PostgREST would reject it. - it('in() encrypts each element and leaves plaintext arrays alone', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id').in('email', ['a@b.com', 'c@d']) - await es.from('users', usersV2).select('id').in('note', ['x', 'y']) - - const [encrypted] = supabase.callsFor('filter') - expect(encrypted.args).toEqual([ - 'email', - 'in', - '("(\\"a@b.com\\")","(\\"c@d\\")")', - ]) - - const [plain] = supabase.callsFor('in') - expect(plain.args[1]).toEqual(['x', 'y']) - }) - - it('is() leaves the value untouched on an encrypted column', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id').is('email', null) - - const [is] = supabase.callsFor('is') - expect(is.args).toEqual(['email', null]) - }) - - it('filter() encrypts the operand on an encrypted column', async () => { - const { es, supabase } = v2Instance() - - await es - .from('users', usersV2) - .select('id') - .filter('email', 'eq', 'a@b.com') - await es.from('users', usersV2).select('id').filter('note', 'eq', 'plain') - - const [encrypted, plain] = supabase.callsFor('filter') - expect(encrypted.args).toEqual(['email', 'eq', '("a@b.com")']) - expect(plain.args).toEqual(['note', 'eq', 'plain']) - }) - - // The single most important characterization test: a strict nonempty SUBSET - // of the or-string's conditions is encrypted, so the condition index `j` must - // agree between the two `parseOrString` calls that `toDbSpace()` collapses - // into one. - it('or() rebuilds a mixed encrypted/plaintext string, keeping each condition on its own column', async () => { - const { es, supabase } = v2Instance() - - await es - .from('users', usersV2) - .select('id') - .or('email.eq.a@b.com,note.eq.x') - - const [or] = supabase.callsFor('or') - const emitted = or.args[0] as string - const [emailCond, noteCond] = emitted.split(',') - expect(emailCond).toContain('email.eq.') - expect(emailCond).toContain('a@b.com') - expect(noteCond).toBe('note.eq.x') - }) - - it('keeps every filter array correlated in a combined query', async () => { - const { es, supabase } = v2Instance() - - await es - .from('users', usersV2) - .select('id, email, age') - .eq('email', 'a@b.com') - .not('age', 'eq', 30) - .or('email.eq.c@d,note.eq.x') - .match({ email: 'e@f.com' }) - .filter('age', 'gte', 18) - .order('age') - .limit(10) - - expect(supabase.callsFor('eq')[0].args).toEqual(['email', '("a@b.com")']) - expect(supabase.callsFor('not')[0].args).toEqual(['age', 'eq', '("30")']) - expect(supabase.callsFor('or')[0].args[0]).toContain('note.eq.x') - expect( - (supabase.callsFor('match')[0].args[0] as Record).email, - ).toBe('("e@f.com")') - expect(supabase.callsFor('filter')[0].args).toEqual([ - 'age', - 'gte', - '("18")', - ]) - expect(supabase.callsFor('order')[0].args[0]).toBe('age') - expect(supabase.callsFor('limit')[0].args[0]).toBe(10) - }) - - // Released-side regressions. `is` is a SQL predicate — PostgREST accepts only - // null/true/false — and a null operand is SQL NULL, never a value to search - // for. Only the regular `.is()` filter skipped encryption; every other - // collector encrypted whatever it was handed, emitting operands PostgREST - // rejects. - describe('is / null operands are never encrypted', () => { - it('does not encrypt not(col, is, null)', async () => { - const { es, supabase } = v2Instance() - await es.from('users', usersV2).select('id').not('age', 'is', null) - expect(supabase.callsFor('not')[0].args).toEqual(['age', 'is', null]) - }) - - it('does not encrypt a raw filter() is operand', async () => { - const { es, supabase } = v2Instance() - await es.from('users', usersV2).select('id').filter('age', 'is', null) - expect(supabase.callsFor('filter')[0].args).toEqual(['age', 'is', null]) - }) - - it('forwards an or() is condition unencrypted', async () => { - const { es, supabase } = v2Instance() - await es.from('users', usersV2).select('id').or('age.is.null') - expect(supabase.callsFor('or')[0].args[0]).toBe('age.is.null') - }) - - it('does not encrypt a null eq() operand', async () => { - const { es, supabase } = v2Instance() - await es.from('users', usersV2).select('id').eq('email', null) - expect(supabase.callsFor('eq')[0].args).toEqual(['email', null]) - }) - - it('does not encrypt a null match() value', async () => { - const { es, supabase } = v2Instance() - await es.from('users', usersV2).select('id').match({ email: null }) - expect(supabase.callsFor('match')[0].args[0]).toEqual({ email: null }) - }) - - it('does not encrypt null elements of an in() list', async () => { - const { es, supabase } = v2Instance() - await es - .from('users', usersV2) - .select('id') - .in('email', ['a@b.com', null]) - // `null` stays a bare PostgREST `null`, never a ciphertext. - expect(supabase.callsFor('filter')[0].args).toEqual([ - 'email', - 'in', - '("(\\"a@b.com\\")",null)', - ]) - }) - - it('treats is() as a predicate even with a non-null operand', async () => { - const { es, supabase } = v2Instance() - await es.from('users', usersV2).select('id').is('email', false) - expect(supabase.callsFor('is')[0].args).toEqual(['email', false]) - }) - }) -}) - // --------------------------------------------------------------------------- // Property → DB name collision // @@ -1765,37 +1476,33 @@ describe('v3 raw filter() resolves the query type from the operator', () => { ]) }) - // `encryptCollectedTerms` rejects any queryType outside the four supported EQL + // `assertTermQueryable` rejects any queryType outside the four supported EQL // v3 kinds. No public call path can produce a fifth — `mapFilterOpToQueryType`, // `queryTypeForRawOp` and `queryTypeForOrOp` are exhaustive — so this backstop - // is unreachable without breaking the internal contract, which is exactly what - // the subclass below does. Keep the guard: it is what a future producer - // gaining a new QueryTypeName would trip over. (`searchableJson` used to be - // the exemplar here until #650 made it a real, supported kind.) - it('rejects a query type outside the supported EQL v3 kinds', async () => { - const supabase = createMockSupabase() + // is only reachable by handing the resolver a term the internal contract says + // cannot exist. Keep the guard: it is what a future producer gaining a new + // QueryTypeName would trip over. (`searchableJson` used to be the exemplar + // here until #650 made it a real, supported kind.) + it('rejects a query type outside the supported EQL v3 kinds', () => { + const term = { + value: 'a@b.com', + column: users.columnBuilders.email, + table: users, + queryType: 'steVecSelector', + returnType: 'composite-literal', + } as unknown as Parameters[0] - class BogusQueryType extends EncryptedQueryBuilderV3Impl { - protected override queryTypeForRawOp(_operator: string) { - return 'steVecSelector' as never - } - } - - const builder = new BogusQueryType( - 'users', - users, - createMockEncryptionClient(), - supabase.client, - USERS_ALL_COLUMNS, - ) - - const { error, status } = await builder - .select('id') - .filter('email', 'eq', 'a@b.com') - - expect(status).toBe(500) - expect(error?.message).toContain('query type "steVecSelector"') - expect(error?.message).toContain('not supported on EQL v3 columns') + expect(() => + assertTermQueryable(term, { + tableName: 'users', + table: users, + encryptionClient: createMockEncryptionClient(), + lockContext: null, + auditConfig: null, + columns: new ColumnMap('users', users, USERS_ALL_COLUMNS), + queryDomainsRequired: false, + }), + ).toThrow(/query type "steVecSelector".*not supported on EQL v3 columns/s) }) }) diff --git a/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts b/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts index 622af6227..93bab05f9 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts @@ -1,9 +1,13 @@ import { encryptedTable, types } from '@cipherstash/stack/eql/v3' +import { + encryptedColumn, + encryptedTable as v2EncryptedTable, +} from '@cipherstash/stack/schema' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SupabaseClientLike } from '../src/index.js' import { encryptedSupabaseV3 } from '../src/index.js' import type { IntrospectionData } from '../src/introspect' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' +import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder' // --- Mocks ----------------------------------------------------------------- // @@ -101,6 +105,29 @@ describe('encryptedSupabaseV3 factory', () => { expect(encryptionMock).not.toHaveBeenCalled() }) + it('rejects a v2 table in schemas before introspection results are used', async () => { + // The realistic caller mistake this guards: migrating from v2 and passing + // the old `schemas` through. A v2 table has `tableName` and + // `columnBuilders` just like a v3 one, so it sails past the record-key + // check and dies deeper in — previously at `verify.ts`, as + // `builder.getEqlType is not a function`, which names an internal method + // rather than the version mismatch. + const users = v2EncryptedTable('users', { + email: encryptedColumn('email').equality(), + }) + + await expect( + encryptedSupabaseV3(fakeClient, { + databaseUrl: 'postgres://x', + schemas: { users } as never, + }), + ).rejects.toThrow( + /\[supabase v3\]: schemas entry "users" is an EQL v2 table/, + ) + // The client must never be built from a schema set we could not validate. + expect(encryptionMock).not.toHaveBeenCalled() + }) + it('passes only non-empty tables to Encryption', async () => { introspectMock.mockResolvedValue( introspectionOf({ @@ -259,9 +286,9 @@ describe('encryptedSupabaseV3 factory', () => { }) }) - // `eqlVersion` is forced, not defaulted. A caller who passes `eqlVersion: 2` - // against v3 domains would otherwise get a v2 encryption client and fail at - // runtime with a 23514 CHECK violation, far from the cause. + // `eqlVersion` is forced, not defaulted. Without the force a caller's + // `eqlVersion: 2` would now make `Encryption` throw at setup, against the + // all-v3 schema set introspection synthesizes. it('forces eqlVersion 3 over a caller-supplied config, passing other keys through', async () => { await encryptedSupabaseV3(fakeClient, { databaseUrl: 'postgres://x', diff --git a/packages/stack-supabase/__tests__/supabase-v3-json.test.ts b/packages/stack-supabase/__tests__/supabase-v3-json.test.ts index f829f0024..bbf747f57 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-json.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-json.test.ts @@ -12,7 +12,7 @@ import { encryptedTable, types } from '@cipherstash/stack/eql/v3' import { describe, expect, it } from 'vitest' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' +import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder' import { createMockEncryptionClient, createMockSupabase, diff --git a/packages/stack-supabase/__tests__/supabase-v3-matrix.test.ts b/packages/stack-supabase/__tests__/supabase-v3-matrix.test.ts index b4306a094..6f566dbb0 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-matrix.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-matrix.test.ts @@ -29,7 +29,7 @@ import { V3_MATRIX, } from '@cipherstash/test-kit/catalog' import { describe, expect, it } from 'vitest' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' +import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder' import { createMockEncryptionClient, createMockSupabase, diff --git a/packages/stack-supabase/__tests__/supabase-v3-select-star.test.ts b/packages/stack-supabase/__tests__/supabase-v3-select-star.test.ts index 1fd76f220..2d7a09a3b 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-select-star.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-select-star.test.ts @@ -1,7 +1,7 @@ import type { EncryptionClient } from '@cipherstash/stack/encryption' import { encryptedTable, types } from '@cipherstash/stack/eql/v3' import { describe, expect, it } from 'vitest' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' +import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder' /** * Supabase double that records the select string AND simulates the part of diff --git a/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts b/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts index 61953f6a7..3ffc94d92 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts @@ -10,9 +10,12 @@ import { encryptedTable, types } from '@cipherstash/stack/eql/v3' import { describe, expect, it } from 'vitest' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' +import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder' import { createWirePostgrest } from './helpers/postgrest-wire' -import { createMockEncryptionClient } from './helpers/supabase-mock' +import { + createMockEncryptionClient, + wasmAuthoredV3Table, +} from './helpers/supabase-mock' const users = encryptedTable('users', { email: types.TextSearch('email'), @@ -215,3 +218,71 @@ describe('plaintext not(col, contains, …) emits a parseable containment litera expect(wire.operandFor('note')).toBe('not.cs.{vip}') }) }) + +describe('a structurally-v3 table still encrypts the filter operand', () => { + // The regression this plan exists to stop, at the layer where it hurt: with + // the `instanceof` gate, a table that is structurally v3 but not an instance + // of THIS package's copy of `EncryptedV3Column` sent `eq.` to + // PostgREST. + it('emits an envelope, not the bare plaintext', async () => { + const wire = createWirePostgrest([]) + const builder = new EncryptedQueryBuilderV3Impl( + 'users', + wasmAuthoredV3Table('users', ['email']) as never, + createMockEncryptionClient(), + wire.client, + ['id', 'email'], + ) + + await builder.select('id').eq('email', 'ada@example.com') + + const operand = wire.operandFor('email') + expect(operand.startsWith('eq.')).toBe(true) + const value = operand.slice('eq.'.length) + + // NOT `expect(operand).not.toContain('ada@example.com')`: the encryption + // double deliberately carries the plaintext in the envelope's `pt` field so + // its fake decrypt can undo it (`helpers/supabase-mock.ts`). The contract + // being pinned is that the operand is an ENVELOPE rather than the raw + // value — unfixed, `value` is literally `ada@example.com`. + expect(value).not.toBe('ada@example.com') + expect(JSON.parse(value)).toMatchObject({ c: 'ct:ada@example.com' }) + }) +}) + +describe('an unrecognised column builder fails closed', () => { + // The mirror image of the leak above: a builder that does NOT present the v3 + // surface must never be silently demoted to plaintext passthrough, because + // then its filter operands would reach PostgREST in the clear. `ColumnMap` is + // built eagerly in the query-builder constructor, so construction throws + // BEFORE any request — this pins that no query string is ever emitted. + it('throws at construction, so no PostgREST request is issued', () => { + const wire = createWirePostgrest([]) + const malformed = { + tableName: 'users', + columnBuilders: { + email: { getName: () => 'email', build: () => ({}) }, + }, + buildColumnKeyMap: () => ({ email: 'email' }), + build: () => ({ tableName: 'users', columns: {} }), + } + + expect( + () => + new EncryptedQueryBuilderV3Impl( + 'users', + malformed as never, + createMockEncryptionClient(), + wire.client, + ['id', 'email'], + ), + // Prefix-only would not discriminate — see the matching note in + // `supabase-schema-builder.test.ts`. This test guards the HARM, so it is + // the one that most needs to fail if the fail-closed probe stops firing + // and some other `[supabase v3]` error surfaces in its place. + ).toThrow( + /\[supabase v3\]: column "email" on table "users" is not a recognised EQL v3 column builder/, + ) + expect(wire.urls).toEqual([]) + }) +}) diff --git a/packages/stack-supabase/__tests__/supabase-v3.test-d.ts b/packages/stack-supabase/__tests__/supabase-v3.test-d.ts index 2bd1a5724..f1b819230 100644 --- a/packages/stack-supabase/__tests__/supabase-v3.test-d.ts +++ b/packages/stack-supabase/__tests__/supabase-v3.test-d.ts @@ -9,7 +9,9 @@ import { } from '@cipherstash/stack/schema' import { describe, expectTypeOf, it } from 'vitest' import { + type EncryptedQueryBuilder, type EncryptedQueryBuilderV3, + type EncryptedSingleQueryBuilder, type EncryptedSupabaseResponse, encryptedSupabase, encryptedSupabaseV3, @@ -335,21 +337,6 @@ describe('encryptedSupabaseV3 untyped surface (no schemas)', () => { builder.contains('tags', 'vip') }) - it('keeps like/ilike on the v2 builder', () => { - const v2Users = v2EncryptedTable('users', { - email: encryptedColumn('email').freeTextSearch(), - }) - const v2 = encryptedSupabase({ - encryptionClient: {} as never, - supabaseClient, - }) - const builder = v2.from<{ email: string }>('users', v2Users) - builder.like('email', '%ada%') - builder.ilike('email', '%ada%') - // @ts-expect-error — contains is the v3 dialect's method - builder.contains('email', 'ada') - }) - it('supports a no-arg select(), like supabase-js', async () => { const supabase = await encryptedSupabaseV3(supabaseClient) supabase.from('users').select() @@ -445,3 +432,140 @@ describe('withLockContext accepts the plain { identityClaim } form (not only Loc mixedBuilder.withLockContext({ claim: ['sub'] }) }) }) + +// --------------------------------------------------------------------------- +// De-suffixed canonical names (encryptedSupabase / EncryptedQueryBuilder) +// +// `encryptedSupabaseV3` and `EncryptedQueryBuilderV3` remain as type-identical +// `@deprecated` aliases (exercised throughout the tests above); these assert the +// unsuffixed names are the same surface. +// --------------------------------------------------------------------------- + +describe('canonical (unsuffixed) exports', () => { + it('encryptedSupabase is the same factory as encryptedSupabaseV3', () => { + expectTypeOf(encryptedSupabase).toEqualTypeOf(encryptedSupabaseV3) + }) + + it('EncryptedQueryBuilder is the same type as EncryptedQueryBuilderV3', () => { + expectTypeOf<EncryptedQueryBuilder<typeof users, UserRow>>().toEqualTypeOf< + EncryptedQueryBuilderV3<typeof users, UserRow> + >() + }) + + it('a typed builder narrows to the documented shape', async () => { + const supabase = await encryptedSupabase(supabaseClient, { + schemas: { users }, + }) + expectTypeOf(supabase.from('users')).toEqualTypeOf< + EncryptedQueryBuilder<typeof users, UserRow> + >() + }) +}) + +// --------------------------------------------------------------------------- +// single() / maybeSingle() +// +// The single-row builder is a DIFFERENT type from the array builder, so every +// method it does and does not carry is public API. Filters and transforms are +// deliberately absent — one applied after `single()` would change the query the +// single-row promise was made about. What stays available is exactly `then`, +// `abortSignal`, `throwOnError`, `returns`, `withLockContext` and `audit`; this +// is NOT parity with postgrest-js, whose `overrideTypes` and `setHeader` have no +// adapter equivalent. `returns<U>()` in particular is documented in the +// changeset for this change and was unreachable from the public surface until +// now (#772 review, SB-1). +// --------------------------------------------------------------------------- + +/** A typed builder for the single-row assertions below. */ +type MixedRow = UserRow & { + tags: string[] + meta: Record<string, unknown> + note: string +} + +describe('single-row builder surface', () => { + it('is exported so a stored builder can be annotated', () => { + expectTypeOf<EncryptedSingleQueryBuilder<UserRow>>().toMatchTypeOf< + PromiseLike<EncryptedSupabaseResponse<UserRow>> + >() + }) + + it('awaits to ONE row, not an array', async () => { + const { data } = await mixedBuilder.single() + expectTypeOf(data).toEqualTypeOf<MixedRow | null>() + }) + + it('carries returns<U>() with the single-row shape preserved', async () => { + const { data } = await mixedBuilder.single().returns<UserRow>() + expectTypeOf(data).toEqualTypeOf<UserRow | null>() + }) + + it('carries the encryption configurators, which are read at execute time', () => { + const builder = mixedBuilder.single() + expectTypeOf( + builder.withLockContext({ identityClaim: ['sub'] }), + ).toEqualTypeOf<EncryptedSingleQueryBuilder<MixedRow>>() + expectTypeOf(builder.audit({ metadata: { a: 1 } })).toEqualTypeOf< + EncryptedSingleQueryBuilder<MixedRow> + >() + }) + + it('does NOT carry filters or transforms', () => { + const builder = mixedBuilder.single() + // @ts-expect-error - a filter after single() would change the query + builder.eq('email', 'a@b.com') + // @ts-expect-error - a transform after single() would change the query + builder.limit(1) + // @ts-expect-error - single() is applied last + builder.single() + }) +}) + +// --------------------------------------------------------------------------- +// Row-type generics accept an `interface` +// +// DO NOT "simplify" `InterfaceRow` below into a `type` alias — the whole point +// of this block is the distinction. A `type` alias for an object literal gets an +// IMPLICIT INDEX SIGNATURE, so it satisfies `Record<string, unknown>`; an +// `interface` does NOT, so an interface row type fails a +// `Row extends Record<string, unknown>` constraint with TS2344 ("Index signature +// for type 'string' is missing"). Every other row-typed test in this file goes +// through `type UserRow = InferPlaintext<typeof users>` (line ~33) — an alias — +// which is exactly why none of them ever caught this. +// +// Interfaces are the ordinary way a Supabase user declares a row type (and what +// `supabase gen types` emits alongside its aliases), and upstream postgrest-js +// leaves the equivalent `returns<T>()` type parameter entirely unconstrained, so +// the adapter must not be stricter than the API it mirrors. +// --------------------------------------------------------------------------- + +interface InterfaceRow { + id: string + email: string +} + +describe('row-type generics accept an interface (not just a type alias)', () => { + it('accepts an interface on the untyped instance from<Row>()', async () => { + const supabase = await encryptedSupabase(supabaseClient) + const { data } = await supabase.from<InterfaceRow>('users').select('*') + expectTypeOf(data).toEqualTypeOf<InterfaceRow[] | null>() + }) + + it('accepts an interface on the typed instance fallback from<Row>()', async () => { + const supabase = await encryptedSupabase(supabaseClient, { + schemas: { users }, + }) + const { data } = await supabase.from<InterfaceRow>('orders').select('*') + expectTypeOf(data).toEqualTypeOf<InterfaceRow[] | null>() + }) + + it('accepts an interface on returns<U>()', async () => { + const { data } = await mixedBuilder.returns<InterfaceRow>() + expectTypeOf(data).toEqualTypeOf<InterfaceRow[] | null>() + }) + + it('accepts an interface on single().returns<U>()', async () => { + const { data } = await mixedBuilder.single().returns<InterfaceRow>() + expectTypeOf(data).toEqualTypeOf<InterfaceRow | null>() + }) +}) diff --git a/packages/stack-supabase/__tests__/supabase.test.ts b/packages/stack-supabase/__tests__/supabase.test.ts deleted file mode 100644 index 1023bc387..000000000 --- a/packages/stack-supabase/__tests__/supabase.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import 'dotenv/config' - -import { Encryption } from '@cipherstash/stack' -import { encryptedColumn, encryptedTable } from '@cipherstash/stack/schema' -import { createClient } from '@supabase/supabase-js' -import postgres from 'postgres' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { encryptedSupabase } from '../src/index.js' - -// supabase.test.ts needs a live Supabase project, so the suite is skipped -// when the Supabase environment is not configured (e.g. in CI, pending a -// containerised Supabase setup). It runs locally when SUPABASE_URL, -// SUPABASE_ANON_KEY, and DATABASE_URL are all set. -const SUPABASE_ENABLED = Boolean( - process.env.SUPABASE_URL && - process.env.SUPABASE_ANON_KEY && - process.env.DATABASE_URL, -) - -const supabase = SUPABASE_ENABLED - ? createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!) - : (undefined as unknown as ReturnType<typeof createClient>) - -const table = encryptedTable('protect-ci', { - encrypted: encryptedColumn('encrypted').freeTextSearch().equality(), - age: encryptedColumn('age').dataType('number').equality(), - // orderAndRange backs the encrypted range test below — the "range filtering - // works on Supabase" claim needs a CI-covered v2 baseline, not just the - // one-off live spike (see the v3 suite's matching range test). - score: encryptedColumn('score').dataType('number').equality().orderAndRange(), -}) - -// Row type for the protect-ci table -type ProtectCiRow = { - id: number - encrypted: string - age: number - score: number - otherField: string - test_run_id: string -} - -// Unique identifier for this test run to isolate data from concurrent test runs -// This is stored in a dedicated test_run_id column to avoid polluting test data -const TEST_RUN_ID = `test-run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - -// Track all inserted IDs for cleanup -const insertedIds: number[] = [] - -beforeAll(async () => { - // Idempotent fixture setup. The `protect-ci` table is shared across the - // drizzle + protect/supabase + stack/supabase integration suites; each - // suite's beforeAll runs the same CREATE TABLE so a fresh database is - // ready without manual DBA work. The schema is the union of every - // column those suites read or write. - const sql = postgres(process.env.DATABASE_URL as string, { prepare: false }) - try { - await sql` - CREATE TABLE IF NOT EXISTS "protect-ci" ( - id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - email eql_v2_encrypted, - age eql_v2_encrypted, - score eql_v2_encrypted, - profile eql_v2_encrypted, - encrypted eql_v2_encrypted, - "otherField" TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - test_run_id TEXT - ) - ` - // Backfill any column added after the table was first created on a - // long-lived CI database. CREATE TABLE IF NOT EXISTS is a no-op on - // those, so new columns need an explicit ADD COLUMN IF NOT EXISTS. - await sql` - ALTER TABLE "protect-ci" ADD COLUMN IF NOT EXISTS "otherField" TEXT - ` - // Tell PostgREST to refresh its schema cache so the supabase-js client - // can see a freshly created table without waiting for the polling - // interval. No-op on plain Postgres (no listener bound). - await sql`NOTIFY pgrst, 'reload schema'` - } finally { - await sql.end() - } - - // Clean up any data from this specific test run (safe for concurrent runs) - const { error } = await supabase - .from('protect-ci') - .delete() - .eq('test_run_id', TEST_RUN_ID) - - if (error) { - console.warn(`[protect]: Failed to clean up test data: ${error.message}`) - } -}, 30000) - -afterAll(async () => { - // Clean up all data from this test run - if (insertedIds.length > 0) { - const { error } = await supabase - .from('protect-ci') - .delete() - .in('id', insertedIds) - if (error) { - console.error(`[protect]: Failed to clean up test data: ${error.message}`) - } - } -}, 30000) - -describe.skipIf(!SUPABASE_ENABLED)( - 'supabase (encryptedSupabase wrapper)', - () => { - it('should insert and select encrypted data', async () => { - const protectClient = await Encryption({ schemas: [table] }) - const eSupabase = encryptedSupabase({ - encryptionClient: protectClient, - supabaseClient: supabase, - }) - - const plaintext = 'hello world' - - // Insert — auto-encrypts the `encrypted` column, auto-converts to PG composite - const { data: insertedData, error: insertError } = await eSupabase - .from<ProtectCiRow>('protect-ci', table) - .insert({ - encrypted: plaintext, - test_run_id: TEST_RUN_ID, - }) - .select('id') - - if (insertError) { - throw new Error(`[protect]: ${insertError.message}`) - } - - insertedIds.push(insertedData![0].id) - - // Select — auto-adds ::jsonb cast to `encrypted`, auto-decrypts result - const { data, error } = await eSupabase - .from<ProtectCiRow>('protect-ci', table) - .select('id, encrypted') - .eq('id', insertedData![0].id) - - if (error) { - throw new Error(`[protect]: ${error.message}`) - } - - expect(data).toHaveLength(1) - expect(data![0].encrypted).toBe(plaintext) - }, 30000) - - it('should insert and select encrypted model data', async () => { - const protectClient = await Encryption({ schemas: [table] }) - const eSupabase = encryptedSupabase({ - encryptionClient: protectClient, - supabaseClient: supabase, - }) - - const model = { - encrypted: 'hello world', - otherField: 'not encrypted', - } - - // Insert — auto-encrypts `encrypted`, passes `otherField` through - const { data: insertedData, error: insertError } = await eSupabase - .from<ProtectCiRow>('protect-ci', table) - .insert({ - ...model, - test_run_id: TEST_RUN_ID, - }) - .select('id') - - if (insertError) { - throw new Error(`[protect]: ${insertError.message}`) - } - - insertedIds.push(insertedData![0].id) - - // Select — auto-adds ::jsonb to `encrypted`, auto-decrypts - const { data, error } = await eSupabase - .from<ProtectCiRow>('protect-ci', table) - .select('id, encrypted, otherField') - .eq('id', insertedData![0].id) - - if (error) { - throw new Error(`[protect]: ${error.message}`) - } - - expect(data).toHaveLength(1) - expect({ - encrypted: data![0].encrypted, - otherField: data![0].otherField, - }).toEqual(model) - }, 30000) - - it('should insert and select bulk encrypted model data', async () => { - const protectClient = await Encryption({ schemas: [table] }) - const eSupabase = encryptedSupabase({ - encryptionClient: protectClient, - supabaseClient: supabase, - }) - - const models = [ - { - encrypted: 'hello world 1', - otherField: 'not encrypted 1', - }, - { - encrypted: 'hello world 2', - otherField: 'not encrypted 2', - }, - ] - - // Bulk insert — auto-encrypts all models - const { data: insertedData, error: insertError } = await eSupabase - .from<ProtectCiRow>('protect-ci', table) - .insert(models.map((m) => ({ ...m, test_run_id: TEST_RUN_ID }))) - .select('id') - - if (insertError) { - throw new Error(`[protect]: ${insertError.message}`) - } - - insertedIds.push(...insertedData!.map((d) => d.id)) - - // Select — auto-decrypts all results - const { data, error } = await eSupabase - .from<ProtectCiRow>('protect-ci', table) - .select('id, encrypted, otherField') - .in( - 'id', - insertedData!.map((d) => d.id), - ) - - if (error) { - throw new Error(`[protect]: ${error.message}`) - } - - expect( - data!.map((d) => ({ - encrypted: d.encrypted, - otherField: d.otherField, - })), - ).toEqual(models) - }, 30000) - - it('should insert and query encrypted number data with equality', async () => { - const protectClient = await Encryption({ schemas: [table] }) - const eSupabase = encryptedSupabase({ - encryptionClient: protectClient, - supabaseClient: supabase, - }) - - const testAge = 25 - const model = { - age: testAge, - otherField: 'not encrypted', - } - - // Insert — auto-encrypts `age` - const { data: insertedData, error: insertError } = await eSupabase - .from<ProtectCiRow>('protect-ci', table) - .insert({ - ...model, - test_run_id: TEST_RUN_ID, - }) - .select('id') - - if (insertError) { - throw new Error(`[protect]: ${insertError.message}`) - } - - insertedIds.push(insertedData![0].id) - - // Query by encrypted `age` — auto-encrypts the search term - const { data, error } = await eSupabase - .from<ProtectCiRow>('protect-ci', table) - .select('id, age, otherField') - .eq('age', testAge) - .eq('test_run_id', TEST_RUN_ID) - - if (error) { - throw new Error(`[protect]: ${error.message}`) - } - - // Verify we found our specific row with encrypted age match - expect(data).toHaveLength(1) - expect(data![0].age).toBe(testAge) - }, 30000) - - // Encrypted RANGE filtering on Supabase previously had no CI coverage — - // the custom ORE operator's resolution over PostgREST rested on a one-off - // live spike. This is the v2 baseline the v3 suite's range test mirrors. - // Range needs real ORE terms (live ZeroKMS), same gating as the suite. - // Note: range FILTERING only — ORDER BY on an encrypted column is - // unsupported on Supabase (operator families need superuser). - it('should filter encrypted number data with a range (gte/lte)', async () => { - const protectClient = await Encryption({ schemas: [table] }) - const eSupabase = encryptedSupabase({ - encryptionClient: protectClient, - supabaseClient: supabase, - }) - - const models = [ - { score: 10, otherField: 'low' }, - { score: 25, otherField: 'mid' }, - { score: 90, otherField: 'high' }, - ] - - const { data: insertedData, error: insertError } = await eSupabase - .from<ProtectCiRow>('protect-ci', table) - .insert(models.map((m) => ({ ...m, test_run_id: TEST_RUN_ID }))) - .select('id') - - if (insertError) { - throw new Error(`[protect]: ${insertError.message}`) - } - - insertedIds.push(...insertedData!.map((d) => d.id)) - - const { data, error } = await eSupabase - .from<ProtectCiRow>('protect-ci', table) - .select('id, score, otherField') - .gte('score', 20) - .lte('score', 30) - .eq('test_run_id', TEST_RUN_ID) - - if (error) { - throw new Error(`[protect]: ${error.message}`) - } - - expect(data).toHaveLength(1) - expect(data![0].score).toBe(25) - expect(data![0].otherField).toBe('mid') - }, 30000) - }, -) diff --git a/packages/stack-supabase/integration/wire.integration.test.ts b/packages/stack-supabase/integration/wire.integration.test.ts index 808f69412..cd10218a0 100644 --- a/packages/stack-supabase/integration/wire.integration.test.ts +++ b/packages/stack-supabase/integration/wire.integration.test.ts @@ -33,7 +33,7 @@ import { encryptedTable, types } from '@cipherstash/stack/eql/v3' import { databaseUrl } from '@cipherstash/test-kit' import postgres from 'postgres' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' +import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder' import { makePostgrestClient, reloadSchemaCache } from './helpers/pgrest' import { narrowedQueryTerm, storageEnvelope } from './helpers/v3-envelope' diff --git a/packages/stack-supabase/package.json b/packages/stack-supabase/package.json index 4b0a5bc46..dbd36fe63 100644 --- a/packages/stack-supabase/package.json +++ b/packages/stack-supabase/package.json @@ -51,7 +51,7 @@ "test": "vitest run", "test:types": "vitest --run --typecheck.only", "test:integration": "vitest run --config integration/vitest.config.ts", - "analyze:complexity": "fta src --score-cap 91" + "analyze:complexity": "fta src --score-cap 71" }, "dependencies": { "@cipherstash/stack": "workspace:*" diff --git a/packages/stack-supabase/src/column-map.ts b/packages/stack-supabase/src/column-map.ts new file mode 100644 index 000000000..c3974b0fa --- /dev/null +++ b/packages/stack-supabase/src/column-map.ts @@ -0,0 +1,299 @@ +import { hasBuildColumnKeyMap } from '@cipherstash/stack/adapter-kit' +import type { AnyV3Table } from '@cipherstash/stack/eql/v3' +import type { ColumnSchema } from '@cipherstash/stack/schema' +import type { BuildableQueryColumn } from '@cipherstash/stack/types' +import type { DbName } from './types' + +/** + * The subset of a v3 column builder the dialect relies on. + * + * This is BOTH the type and the runtime gate: `isV3ColumnLike` below probes + * exactly these members. It must not become an `instanceof` check — see that + * function's comment. + */ +export type V3ColumnLike = { + getName(): string + getEqlType(): string + getQueryCapabilities(): { + equality: boolean + orderAndRange: boolean + freeTextSearch: boolean + /** Optional: only `public.eql_v3_json_search` (`types.Json`) carries it. */ + searchableJson?: boolean + } + build(): ColumnSchema +} + +/** + * Whether a column builder is an EQL v3 column, checked STRUCTURALLY. + * + * NOT `instanceof EncryptedV3Column`. tsup emits that class twice — once into + * the chunk `dist/adapter-kit.js` imports, and once inline in + * `dist/wasm-inline.js`, a separate esbuild run + * (`packages/stack/tsup.config.ts:43-52`). A table authored with + * `encryptedTable`/`types` from `@cipherstash/stack/wasm-inline` is built from + * the second copy, so an `instanceof` against the first returned `false` for + * every column: `v3Columns` came out empty and the adapter treated encrypted + * columns as plaintext — filter operands reached PostgREST in the clear, while + * `::jsonb` casts and decryption kept working (they read `buildColumnKeyMap()` + * and the encrypt config, not this map). + * + * Mirrors `hasBuildColumnKeyMap` (`packages/stack/src/types.ts:276-283`), the + * repo's canonical answer to the same problem, used identically at + * `wasm-inline.ts:1361` — including its spelling: `'k' in obj && typeof (obj as + * { k?: unknown }).k === 'function'`, one narrowed probe per member, rather + * than one blanket `as Record<string, unknown>` over the whole object. + * + * Four probes, not two: a v2 column builder has `build()` and `getName()` + * (`EncryptedColumn`, `packages/stack/src/schema/index.ts:442,449`) but neither + * `getEqlType()` nor `getQueryCapabilities()` (`eql/v3/columns.ts:445,450`). + * + * Exported for `__tests__/column-map-predicate.test.ts` only — `column-map.ts` + * is not re-exported from `src/index.ts` and the package publishes just `.`, so + * this does not widen the published surface (`V3ColumnLike` above is exported + * on the same terms). Testing it through `ColumnMap` cannot distinguish a + * four-probe gate from a two-probe one: every builder that reaches the + * constructor either passes every probe or fails two at once. + */ +export function isV3ColumnLike(builder: unknown): builder is V3ColumnLike { + if (typeof builder !== 'object' || builder === null) return false + return ( + 'getName' in builder && + typeof (builder as { getName?: unknown }).getName === 'function' && + 'getEqlType' in builder && + typeof (builder as { getEqlType?: unknown }).getEqlType === 'function' && + 'getQueryCapabilities' in builder && + typeof (builder as { getQueryCapabilities?: unknown }) + .getQueryCapabilities === 'function' && + 'build' in builder && + typeof (builder as { build?: unknown }).build === 'function' + ) +} + +/** + * Reject a declared property name that is also a DIFFERENT physical column. + * + * `select('*')` expands the introspected DB names into property names, so a + * column renamed `created_at → createdAt` and a distinct plaintext column + * literally named `createdAt` both emit the token `createdAt`, which + * `addJsonbCastsV3` turns into `createdAt:created_at::jsonb` — twice. PostgREST + * returns the encrypted column under that key and the plaintext one is never + * selected, silently yielding the wrong value for a field the row type + * guarantees. + * + * Nothing downstream can disambiguate the two, and `EncryptedTable.build()`'s + * duplicate check only fires when two BUILDERS share a `getName()`. Refuse to + * construct instead. + */ +function assertNoPropertyDbNameCollision( + tableName: string, + propToDb: Record<string, string>, + allColumns: string[] | null, +): void { + if (!allColumns) return + const dbNames = new Set(allColumns) + + for (const [property, dbName] of Object.entries(propToDb)) { + if (property === dbName) continue + if (!dbNames.has(property)) continue + throw new Error( + `[supabase v3]: property "${property}" on table "${tableName}" renames DB column "${dbName}", but "${property}" is also a distinct column in the database — the two collide in select('*'). Rename the property, or drop the declared rename.`, + ) + } +} + +/** + * Name and capability resolution for one table's columns. + * + * Every stage of the query pipeline addresses columns by BOTH the JS property + * name a caller wrote and the DB name PostgREST must see, and each needs to ask + * whether a given name is an encrypted v3 column and what it can be queried by. + * Concentrating that here means the encrypt, DB-space, filter-apply and decrypt + * modules take one collaborator instead of six correlated fields that must be + * kept in lockstep. + */ +export class ColumnMap { + /** JS property name → DB column name, for every encrypted column. */ + readonly propToDb: Record<string, string> + /** Built column schemas keyed by DB column name (for `cast_as`, `indexes`). */ + readonly columnSchemas: Record<string, ColumnSchema> + /** Every name an encrypted column answers to — property AND DB spelling. + * Filters and select strings address columns by both, so recognition must + * cover both. */ + readonly encryptedColumnNames: string[] + + /** DB column name → JS property name — the inverse of {@link propToDb}, used + * to expand `select('*')` back into property names. Null prototype: a DB + * column literally named `constructor` / `toString` would otherwise resolve + * to an inherited `Object.prototype` member and be emitted as a select token. */ + private readonly dbToProp: Record<string, string> + /** Column builders keyed by BOTH property name and DB name. */ + private readonly v3Columns: Record<string, V3ColumnLike> + + constructor( + tableName: string, + table: AnyV3Table, + allColumns: string[] | null, + ) { + // FAIL CLOSED at the table level, for the same reason the column loop does + // below. `buildColumnKeyMap()` is the canonical v2/v3 discriminator + // (`packages/stack/src/types.ts:276`), and it is also the very first thing + // this constructor calls — so a v2 table died on the next line with + // `table.buildColumnKeyMap is not a function`, naming an internal method + // instead of the version mismatch. The column-level probe cannot cover + // this: it runs after the constructor has already crashed. + // + // Routed through `hasBuildColumnKeyMap` rather than hand-spelled, per that + // function's own doctrine — a second spelling is how the marker drifts. + if (!hasBuildColumnKeyMap(table)) { + throw new Error( + `[supabase v3]: table "${tableName}" is an EQL v2 table — it has no buildColumnKeyMap(), the marker every v3 table carries. This adapter is EQL v3 only. Author the table with \`encryptedTable\`/\`types\` from \`@cipherstash/stack/eql/v3\` or \`@cipherstash/stack/wasm-inline\`.`, + ) + } + + this.propToDb = table.buildColumnKeyMap() + this.columnSchemas = table.build().columns + + this.dbToProp = Object.create(null) as Record<string, string> + for (const [property, dbName] of Object.entries(this.propToDb)) { + this.dbToProp[dbName] = property + } + + assertNoPropertyDbNameCollision(tableName, this.propToDb, allColumns) + + // Null-prototype: keyed by DB column names, and `validateTransforms` reads + // it without an own-key guard — an inherited `constructor`/`toString` would + // otherwise resolve truthy for a plaintext column of that name. + this.v3Columns = Object.create(null) as Record<string, V3ColumnLike> + for (const [property, builder] of Object.entries(table.columnBuilders)) { + // FAIL CLOSED. `columnBuilders` is typed `EncryptedV3TableColumn` + // (`eql/v3/table.ts:18-25`), so every entry is meant to be an encrypted v3 + // column. A builder that fails the structural probe is malformed input — + // and silently skipping it is the one thing we must not do here: the + // column would drop out of `v3Columns`, `isEncryptedColumn()` would return + // false for it, and its filter operands would go to PostgREST as + // PLAINTEXT. Refuse to construct instead, mirroring `build()`'s + // fail-loudly-on-malformed stance (`eql/v3/table.ts:47-51`). + if (!isV3ColumnLike(builder)) { + throw new Error( + `[supabase v3]: column "${property}" on table "${tableName}" is not a recognised EQL v3 column builder. Its filter operands would otherwise be sent to PostgREST unencrypted, so construction is refused. Author the table with \`encryptedTable\`/\`types\` from \`@cipherstash/stack/eql/v3\` or \`@cipherstash/stack/wasm-inline\`.`, + ) + } + const col = builder + this.v3Columns[property] = col + this.v3Columns[col.getName()] = col + } + + this.encryptedColumnNames = Object.keys(this.v3Columns) + } + + /** Resolve a JS property name to its DB column name. `Object.hasOwn` guards + * the inherited-member hazard described on {@link EncryptedTable.buildColumnKeyMap}. */ + dbNameFor(name: string): string { + return Object.hasOwn(this.propToDb, name) ? this.propToDb[name] : name + } + + /** + * Map a filter's column name to the DB column name PostgREST must see — + * resolving a JS property name to its DB name. + * + * This is the ONLY place a {@link DbName} is minted. The + * {@link SupabaseQueryBuilder} seam accepts nothing else, so every column + * name reaching PostgREST must pass through here. + */ + filterColumnName(column: string): DbName { + return this.dbNameFor(column) as DbName + } + + /** + * Encrypted ordering columns sort by their `op` term, not by the envelope. + * + * `order=col->op` is the one ordering expression PostgREST can emit that + * reaches the OPE term. It must NOT leak into filters — those compare whole + * envelopes through the `eql_v3.*` operators — which is why this is its own + * seam rather than a change to {@link filterColumnName}. + * + * The canonical EQL form is `ORDER BY eql_v3.ord_term(col)`, which returns + * `eql_v3_internal.ope_cllw` — a domain over `bytea`, ordered by the native + * btree. PostgREST cannot call a function, so it orders the `op` term where it + * sits, inside the envelope. The two agree because the term is what + * `ord_term()` returns. + * + * `->` (jsonb) rather than `->>` (text) keeps the comparison on the typed + * value. Note this does NOT avoid the database collation: Postgres compares + * jsonb strings with `varstr_cmp` under the default collation, exactly as it + * does text. What makes the ordering collation-independent is the term itself + * — fixed-width lowercase hex (`[0-9a-f]`, 130 chars for `integer_ord`, 82 for + * `text_search`) — and every collation orders digits before letters and hex + * letters among themselves. `match-bloom`'s sibling assertion pins that shape. + * + * This runs at column-name-mapping time (`transformToDbSpace`), BEFORE + * `buildAndExecuteQuery` calls `validateTransforms`. For an encrypted column + * with no `ope` index it therefore returns a bare `dbName` here — a name that + * would sort by `jsonb_cmp` over the ciphertext if it reached PostgREST — but + * it never does: `validateTransforms` throws (with a domain-specific reason) + * before the query executes, so the bare name is only ever an intermediate + * value on a request that is about to be rejected. + */ + orderColumnName(column: string): DbName { + const dbName = this.dbNameFor(column) + const encrypted = this.v3Columns[column] + if (!encrypted) return dbName as DbName + + return ( + this.columnSchemas[dbName]?.indexes?.ope ? `${dbName}->op` : dbName + ) as DbName + } + + /** + * Expand the introspected column list (DB names) into JS property names. + * + * Load-bearing for `select('*')` on a DECLARED table that renames a column. + * `addJsonbCastsV3` only emits the `prop:db_name::jsonb` alias — the thing + * that makes PostgREST return the column under its property name — when the + * token it sees is a property name. Feeding it the raw DB name instead takes + * the unaliased `dbNames.has(...)` branch, so the row comes back keyed + * `created_at` while the declared row type promises `createdAt`, silently + * yielding `undefined` for a field TypeScript guarantees. + * + * A DB column with no encrypted builder (plaintext passthrough, and every + * synthesized column, where property == DB name) maps to itself. + */ + expandAllColumns(columns: string[]): string[] { + return columns.map((dbName) => + Object.hasOwn(this.dbToProp, dbName) ? this.dbToProp[dbName] : dbName, + ) + } + + /** True when `column` is one of this table's encrypted v3 columns. */ + isEncryptedV3Column(column: string): boolean { + return Boolean(this.v3Columns[column]) + } + + /** True when `column` is an encrypted `types.Json` document column. */ + isSearchableJsonColumn(column: string): boolean { + const builder: V3ColumnLike | undefined = this.v3Columns[column] + return Boolean(builder?.getQueryCapabilities().searchableJson) + } + + /** The encrypted builder for `column`, by either spelling. */ + encryptedColumn(column: string): V3ColumnLike | undefined { + return this.v3Columns[column] + } + + /** The built schema for a DB column name — `cast_as` and `indexes`. */ + schemaFor(dbName: string): ColumnSchema | undefined { + return this.columnSchemas[dbName] + } + + /** The encrypted builders as the term collector's column lookup. */ + queryColumnMap(): Record<string, BuildableQueryColumn> { + // `V3ColumnLike` omits `isQueryable(): true`, which `BuildableV3QueryableColumn` + // requires — and `v3Columns` intentionally holds storage-only columns, for which + // `isQueryable()` is `false`. The collector consults `getQueryCapabilities()` + // before using an entry (`query-encrypt.ts:513`), so the widening is safe; + // narrowing the type would mean narrowing the map. + // biome-ignore lint/plugin: storage-only v3 columns lack `isQueryable(): true`; widening is safe (see above). + return this.v3Columns as unknown as Record<string, BuildableQueryColumn> + } +} diff --git a/packages/stack-supabase/src/helpers.ts b/packages/stack-supabase/src/helpers.ts index 7d2ae5fc6..23168ffe0 100644 --- a/packages/stack-supabase/src/helpers.ts +++ b/packages/stack-supabase/src/helpers.ts @@ -1,7 +1,3 @@ -import type { - EncryptedTable, - EncryptedTableColumn, -} from '@cipherstash/stack/schema' import type { QueryTypeName } from '@cipherstash/stack/types' import type { DbFilterString, @@ -11,16 +7,6 @@ import type { PendingOrCondition, } from './types' -/** - * Get the names of all encrypted columns defined in a table schema. - */ -export function getEncryptedColumnNames( - schema: EncryptedTable<EncryptedTableColumn>, -): string[] { - const built = schema.build() - return Object.keys(built.columns) -} - /** * Check whether a column name refers to an encrypted column in the schema. */ @@ -31,53 +17,6 @@ export function isEncryptedColumn( return encryptedColumnNames.includes(columnName) } -/** - * Parse a Supabase select string and add `::jsonb` casts to encrypted columns. - * - * Input: `'id, email, name'` - * Output: `'id, email::jsonb, name::jsonb'` (if email and name are encrypted) - * - * Handles whitespace, already-cast columns, and embedded functions. - */ -export function addJsonbCasts( - columns: string, - encryptedColumnNames: string[], -): DbSelect { - // The mapping below emits DB-space tokens; the brand is asserted once, here. - return columns - .split(',') - .map((col) => { - const trimmed = col.trim() - - // Skip empty segments - if (!trimmed) return col - - // If it already has a cast (e.g. `email::jsonb`), skip - if (trimmed.includes('::')) return col - - // If it contains parens (function call) or dots (foreign table), skip - if (trimmed.includes('(') || trimmed.includes('.')) return col - - // Check if the column name (possibly with alias) is encrypted - // Handle `column_name` or `column_name as alias` - const parts = trimmed.split(/\s+/) - const colName = parts[0] - - if (isEncryptedColumn(colName, encryptedColumnNames)) { - // Preserve original whitespace before the column - const leadingWhitespace = col.match(/^(\s*)/)?.[1] ?? '' - if (parts.length > 1) { - // Has alias: `email as e` -> `email::jsonb as e` - return `${leadingWhitespace}${colName}::jsonb ${parts.slice(1).join(' ')}` - } - return `${leadingWhitespace}${colName}::jsonb` - } - - return col - }) - .join(',') as DbSelect -} - /** * Resolve a select token to its DB column name, or `undefined`. * @@ -109,7 +48,7 @@ function lookupDbName( * property name. * - A DB column name used directly is cast in place (`db_name::jsonb`). * - Tokens that already carry a cast, or contain parens/dots (functions, - * foreign tables), are left untouched — same rules as the v2 helper. + * foreign tables), are left untouched. */ export function addJsonbCastsV3( columns: string, diff --git a/packages/stack-supabase/src/index.ts b/packages/stack-supabase/src/index.ts index ec9847261..a05c7b81f 100644 --- a/packages/stack-supabase/src/index.ts +++ b/packages/stack-supabase/src/index.ts @@ -1,78 +1,19 @@ import { Encryption } from '@cipherstash/stack' -import type { - EncryptedTable, - EncryptedTableColumn, -} from '@cipherstash/stack/schema' +import { hasBuildColumnKeyMap } from '@cipherstash/stack/adapter-kit' import type { UnmodelledColumn } from './introspect' import { eqlRequiresQueryDomains, introspect } from './introspect' import { EncryptedQueryBuilderImpl } from './query-builder' -import { EncryptedQueryBuilderV3Impl } from './query-builder-v3' import { mergeDeclaredTables, synthesizeTables } from './schema-builder' import type { - EncryptedQueryBuilder, - EncryptedSupabaseConfig, + EncryptedQueryBuilderUntyped, EncryptedSupabaseInstance, - EncryptedSupabaseV3Instance, - EncryptedSupabaseV3Options, + EncryptedSupabaseOptions, SupabaseClientLike, - TypedEncryptedSupabaseV3Instance, + TypedEncryptedSupabaseInstance, V3Schemas, } from './types' import { verifyDeclaredSchemas } from './verify' -/** - * Create an encrypted Supabase wrapper that transparently handles encryption - * and decryption for queries on encrypted columns. - * - * @param config - Configuration containing the encryption client and Supabase client. - * @returns An object with a `from()` method that mirrors `supabase.from()` but - * auto-encrypts mutations, adds `::jsonb` casts, encrypts filter values, and - * decrypts results. - * - * @example - * ```typescript - * import { Encryption } from '@cipherstash/stack' - * import { encryptedSupabase } from '@cipherstash/stack-supabase' - * import { encryptedTable, encryptedColumn } from '@cipherstash/stack/schema' - * - * const users = encryptedTable('users', { - * name: encryptedColumn('name').freeTextSearch().equality(), - * email: encryptedColumn('email').freeTextSearch().equality(), - * }) - * - * const client = await Encryption({ schemas: [users] }) - * const eSupabase = encryptedSupabase({ encryptionClient: client, supabaseClient: supabase }) - * - * // INSERT - auto-encrypts, auto-converts to PG composite - * await eSupabase.from('users', users) - * .insert({ name: 'John', email: 'john@example.com', age: 30 }) - * - * // SELECT with filter - auto-casts ::jsonb, auto-encrypts search term, auto-decrypts - * const { data } = await eSupabase.from('users', users) - * .select('id, email, name') - * .eq('email', 'john@example.com') - * ``` - */ -export function encryptedSupabase( - config: EncryptedSupabaseConfig, -): EncryptedSupabaseInstance { - const { encryptionClient, supabaseClient } = config - - return { - from<T extends Record<string, unknown> = Record<string, unknown>>( - tableName: string, - schema: EncryptedTable<EncryptedTableColumn>, - ) { - return new EncryptedQueryBuilderImpl<T>( - tableName, - schema, - encryptionClient, - supabaseClient, - ) - }, - } -} - /** * Throw if `tableName` carries an EQL v3 column this SDK version cannot model. * @@ -101,11 +42,22 @@ function assertTableIsModelled( } /** - * Create an encrypted Supabase wrapper for **EQL v3** schemas by introspecting - * the database at connect time. Detects EQL v3 columns by their Postgres domain - * and derives each column's encryption config from it — callers no longer pass a - * schema to `from()`. Supplying `schemas` is optional: it adds compile-time - * types and verifies the declared tables against the database at construction. + * Create an encrypted Supabase wrapper over **native EQL v3 column domains** by + * introspecting the database at connect time. Detects EQL v3 columns by their + * Postgres domain and derives each column's encryption config from it — callers + * do not pass a schema to `from()`. Supplying `schemas` is optional: it adds + * compile-time types and verifies the declared tables against the database at + * construction. + * + * Encrypted data is stored as EQL v3 payloads. This wrapper is EQL v3 only — it + * both authors and reads v3, and the legacy v2 authoring surface (a hand-written + * client-side schema and `from(tableName, schema)`) has been removed. It does + * not auto-read an `eql_v2_encrypted` column: introspection recognises the + * `public.eql_v3_*` domains exclusively, so a v2 column never enters the + * encrypt config and is returned as an untouched passthrough. No v2 ciphertext + * is stranded — decryption in `@cipherstash/stack` is generation-agnostic, so + * legacy payloads still decrypt through the core client (`decrypt` / + * `decryptModel`). Handle mixed-generation data explicitly on the caller side. * * Requires a Postgres connection (`options.databaseUrl` or `DATABASE_URL`) for * introspection, so it cannot run in a Worker or the browser. @@ -125,45 +77,45 @@ function assertTableIsModelled( * * @example * ```typescript - * const supabase = await encryptedSupabaseV3(supabaseUrl, supabaseKey) + * const supabase = await encryptedSupabase(supabaseUrl, supabaseKey) * await supabase.from('users').insert({ email: 'alice@example.com' }) * const { data } = await supabase.from('users').select().eq('email', 'alice@example.com') * ``` */ -export async function encryptedSupabaseV3<S extends V3Schemas>( +export async function encryptedSupabase<S extends V3Schemas>( supabaseUrl: string, supabaseKey: string, - options: EncryptedSupabaseV3Options<S> & { schemas: S }, -): Promise<TypedEncryptedSupabaseV3Instance<S>> -export async function encryptedSupabaseV3( + options: EncryptedSupabaseOptions<S> & { schemas: S }, +): Promise<TypedEncryptedSupabaseInstance<S>> +export async function encryptedSupabase( supabaseUrl: string, supabaseKey: string, - options?: EncryptedSupabaseV3Options, -): Promise<EncryptedSupabaseV3Instance> -export async function encryptedSupabaseV3<S extends V3Schemas>( + options?: EncryptedSupabaseOptions, +): Promise<EncryptedSupabaseInstance> +export async function encryptedSupabase<S extends V3Schemas>( supabaseClient: SupabaseClientLike, - options: EncryptedSupabaseV3Options<S> & { schemas: S }, -): Promise<TypedEncryptedSupabaseV3Instance<S>> -export async function encryptedSupabaseV3( + options: EncryptedSupabaseOptions<S> & { schemas: S }, +): Promise<TypedEncryptedSupabaseInstance<S>> +export async function encryptedSupabase( supabaseClient: SupabaseClientLike, - options?: EncryptedSupabaseV3Options, -): Promise<EncryptedSupabaseV3Instance> -// The implementation's option params are `EncryptedSupabaseV3Options<V3Schemas | + options?: EncryptedSupabaseOptions, +): Promise<EncryptedSupabaseInstance> +// The implementation's option params are `EncryptedSupabaseOptions<V3Schemas | // undefined>`, NOT `<V3Schemas>`. The no-schemas overloads take -// `EncryptedSupabaseV3Options` — i.e. `<undefined>`, whose `schemas` is typed +// `EncryptedSupabaseOptions` — i.e. `<undefined>`, whose `schemas` is typed // `undefined` — and TS2394s against an implementation param whose `schemas` is // typed `V3Schemas`. Widening the type argument to the full constraint makes // every overload relatable to the implementation signature. -export async function encryptedSupabaseV3( +export async function encryptedSupabase( clientOrUrl: SupabaseClientLike | string, - keyOrOptions?: string | EncryptedSupabaseV3Options<V3Schemas | undefined>, - maybeOptions?: EncryptedSupabaseV3Options<V3Schemas | undefined>, + keyOrOptions?: string | EncryptedSupabaseOptions<V3Schemas | undefined>, + maybeOptions?: EncryptedSupabaseOptions<V3Schemas | undefined>, ): Promise< - EncryptedSupabaseV3Instance | TypedEncryptedSupabaseV3Instance<V3Schemas> + EncryptedSupabaseInstance | TypedEncryptedSupabaseInstance<V3Schemas> > { // 1. Resolve the Supabase client + options from the overload shape. let supabaseClient: SupabaseClientLike - let options: EncryptedSupabaseV3Options<V3Schemas | undefined> + let options: EncryptedSupabaseOptions<V3Schemas | undefined> if (typeof clientOrUrl === 'string') { const url = clientOrUrl const key = keyOrOptions as string @@ -180,10 +132,10 @@ export async function encryptedSupabaseV3( if (code !== 'MODULE_NOT_FOUND' && code !== 'ERR_MODULE_NOT_FOUND') throw err throw new Error( - "[supabase v3]: encryptedSupabaseV3(url, key) needs '@supabase/supabase-js' " + + "[supabase v3]: encryptedSupabase(url, key) needs '@supabase/supabase-js' " + 'to build the client, but that optional peer dependency is not installed. ' + 'Install it (`npm install @supabase/supabase-js`), or pass an existing ' + - 'client: encryptedSupabaseV3(supabaseClient, options).', + 'client: encryptedSupabase(supabaseClient, options).', { cause: err }, ) } @@ -191,7 +143,7 @@ export async function encryptedSupabaseV3( } else { supabaseClient = clientOrUrl options = - (keyOrOptions as EncryptedSupabaseV3Options<V3Schemas | undefined>) ?? {} + (keyOrOptions as EncryptedSupabaseOptions<V3Schemas | undefined>) ?? {} } // 2. Resolve the database URL for introspection. @@ -218,6 +170,18 @@ export async function encryptedSupabaseV3( `[supabase v3]: schemas key "${key}" does not match its table name "${table.tableName}" — the record key must equal the table's name`, ) } + // A v2 `EncryptedTable` carries `tableName` and `columnBuilders` exactly + // as a v3 one does, so nothing above this distinguishes them and the + // types only catch it for callers who are actually type-checking. Left + // unguarded it reached `verifyDeclaredSchemas`, which calls + // `builder.getEqlType()` — absent on a v2 column — and died as + // `builder.getEqlType is not a function`: an internal method name, from + // a caller's perspective unrelated to the mistake they made. + if (!hasBuildColumnKeyMap(table)) { + throw new Error( + `[supabase v3]: schemas entry "${key}" is an EQL v2 table — it has no buildColumnKeyMap(), the marker every v3 table carries. This adapter is EQL v3 only. Author the table with \`encryptedTable\`/\`types\` from \`@cipherstash/stack/eql/v3\`.`, + ) + } assertTableIsModelled(key, unmodelled) } verifyDeclaredSchemas(options.schemas, tables) @@ -225,11 +189,11 @@ export async function encryptedSupabaseV3( } // 5. Build the raw (eqlVersion 3) encryption client from the merged tables. - // NB: `Encryption`, not `EncryptionV3` — the query builder consumes the raw - // chainable `EncryptionClient`, whereas `EncryptionV3` returns the typed - // wrapper whose `decryptModel` returns a plain Promise<Result>. Pass only - // tables that carry at least one encrypted column (`Encryption` requires a - // non-empty schema list). + // NB: the query builder consumes the raw chainable `EncryptionClient`, and + // calls `decryptModel(row)` with no table — the typed client degrades to + // nominal (passthrough) behaviour for that arity, so either shape works. + // Pass only tables that carry at least one encrypted column (`Encryption` + // requires a non-empty schema list). const encryptionSchemas = [...synth.tables.values()].filter( (t) => Object.keys(t.columnBuilders).length > 0, ) @@ -273,34 +237,47 @@ export async function encryptedSupabaseV3( // ciphertext for one. Never make it optional. assertTableIsModelled(tableName, unmodelled) const allColumns = synth.allColumns.get(tableName) ?? null - return new EncryptedQueryBuilderV3Impl( + return new EncryptedQueryBuilderImpl( tableName, table, encryptionClient, supabaseClient, allColumns, queryDomainsRequired, - ) as unknown as EncryptedQueryBuilder<Record<string, unknown>> + ) as unknown as EncryptedQueryBuilderUntyped<Record<string, unknown>> }, } return instance as unknown as - | EncryptedSupabaseV3Instance - | TypedEncryptedSupabaseV3Instance<V3Schemas> + | EncryptedSupabaseInstance + | TypedEncryptedSupabaseInstance<V3Schemas> } +/** + * @deprecated Use {@link encryptedSupabase}. `encryptedSupabaseV3` is a + * type-identical alias kept for existing imports; the `V3` suffix is redundant + * now that EQL v3 is the only generation this wrapper authors. + */ +export const encryptedSupabaseV3 = encryptedSupabase + export type { EncryptedQueryBuilder, EncryptedQueryBuilderCore, + EncryptedQueryBuilderUntyped, + // Deprecated `*V3` aliases (Decision 5 — supabase keeps type-identical aliases). EncryptedQueryBuilderV3, EncryptedQueryBuilderV3Untyped, - EncryptedSupabaseConfig, + EncryptedSingleQueryBuilder, EncryptedSupabaseError, EncryptedSupabaseInstance, + EncryptedSupabaseOptions, EncryptedSupabaseResponse, EncryptedSupabaseV3Instance, EncryptedSupabaseV3Options, + FilterableKeys, + FreeTextSearchableKeys, PendingOrCondition, SupabaseClientLike, + TypedEncryptedSupabaseInstance, TypedEncryptedSupabaseV3Instance, V3FilterableKeys, V3FreeTextSearchableKeys, diff --git a/packages/stack-supabase/src/introspect.ts b/packages/stack-supabase/src/introspect.ts index 18501468c..d1619f358 100644 --- a/packages/stack-supabase/src/introspect.ts +++ b/packages/stack-supabase/src/introspect.ts @@ -189,10 +189,10 @@ export async function loadPg( if (code !== 'MODULE_NOT_FOUND' && code !== 'ERR_MODULE_NOT_FOUND') throw err throw new Error( - '[supabase v3]: encryptedSupabaseV3 introspects the database over a direct ' + + '[supabase v3]: encryptedSupabase introspects the database over a direct ' + "Postgres connection, but the optional peer dependency 'pg' is not installed. " + - 'Install it (`npm install pg`). This also means encryptedSupabaseV3 cannot run ' + - 'in a Worker or the browser — use encryptedSupabase (EQL v2) there.', + 'Install it (`npm install pg`). This also means encryptedSupabase cannot run ' + + 'in a Worker or the browser, where a direct Postgres connection is unavailable.', { cause: err }, ) } diff --git a/packages/stack-supabase/src/query-builder-v3.ts b/packages/stack-supabase/src/query-builder-v3.ts deleted file mode 100644 index 8df37536c..000000000 --- a/packages/stack-supabase/src/query-builder-v3.ts +++ /dev/null @@ -1,989 +0,0 @@ -import { - DATE_LIKE_CASTS, - EncryptedV3Column, - logger, - matchNeedleError, - parseSelectorSegments, - reconstructSelectorDocument, - unsupportedLeafReason, -} from '@cipherstash/stack/adapter-kit' -import type { EncryptionClient } from '@cipherstash/stack/encryption' -import type { AnyV3Table } from '@cipherstash/stack/eql/v3' -import { - type EncryptionError, - EncryptionErrorTypes, -} from '@cipherstash/stack/errors' -import type { - ColumnSchema, - EncryptedTable, - EncryptedTableColumn, -} from '@cipherstash/stack/schema' -import type { - BuildableQueryColumn, - Encrypted, - EncryptedQueryResult, - QueryTypeName, - ScalarQueryTerm, -} from '@cipherstash/stack/types' -import { addJsonbCastsV3, selectKeyToDbV3 } from './helpers' -import { - EncryptedQueryBuilderImpl, - EncryptionFailedError, -} from './query-builder' -import type { - DbName, - DbSelect, - FilterOp, - SupabaseClientLike, - SupabaseQueryBuilder, -} from './types' - -/** cast_as kinds that reconstruct to a JS `Date` — shared with the typed v3 - * client's decrypt-model path (see `encryption/v3.ts`). */ -const DATE_LIKE_CAST_SET = new Set<string>(DATE_LIKE_CASTS) - -/** - * The subset of a v3 column builder the dialect relies on. Structural rather - * than the concrete class union so the runtime `instanceof EncryptedV3Column` - * gate and this type stay independent. - */ -type V3ColumnLike = { - getName(): string - getEqlType(): string - getQueryCapabilities(): { - equality: boolean - orderAndRange: boolean - freeTextSearch: boolean - /** Optional: only `public.eql_v3_json_search` (`types.Json`) carries it. */ - searchableJson?: boolean - } - build(): ColumnSchema -} - -/** - * Validate an encrypted-JSON containment operand: a NON-EMPTY plain object or a - * non-empty array. Everything else is rejected with an actionable steer: - * - * - Scalars/strings: the caller meant free-text (`matches` on a text column) or - * a selector — a raw JSON string is NOT parsed, by design (parsing would make - * `'{"a":1}'` and `{a:1}` silently different queries on other surfaces). - * - Non-plain objects (`Date`, `Map`, `RegExp`, class instances): these JSON- - * serialize to scalars or `{}` — not the sub-document the caller believes. - * - `{}` and `[]`: jsonb containment holds for EVERY document (`doc @> '{}'`), - * so an accidentally-empty needle would silently return (and decrypt) the - * whole table. The Drizzle adapter rejects the same needle for the same - * reason — the two first-party adapters must agree that this is an error. - */ -function assertJsonContainmentOperand(column: string, value: unknown): void { - const isPlainObject = - value !== null && - typeof value === 'object' && - !Array.isArray(value) && - (Object.getPrototypeOf(value) === Object.prototype || - Object.getPrototypeOf(value) === null) - if (!isPlainObject && !Array.isArray(value)) { - // Array.isArray is false on this branch by construction, so the label only - // distinguishes null / non-plain object / scalar. - const got = - value === null - ? 'null' - : typeof value === 'object' - ? (value as object).constructor?.name || 'a non-plain object' - : typeof value - throw new Error( - `[supabase v3]: encrypted JSON containment on column "${column}" takes a sub-document (plain object or array) to match, got ${got}.`, - ) - } - const empty = Array.isArray(value) - ? value.length === 0 - : Object.keys(value as object).length === 0 - if (empty) { - throw new Error( - `[supabase v3]: encrypted JSON containment on column "${column}" cannot take an empty ${Array.isArray(value) ? 'array' : 'object'} needle: it matches every row. Pass a non-empty sub-document, or omit the predicate to select all rows.`, - ) - } -} - -/** - * Reject a declared property name that is also a DIFFERENT physical column. - * - * `select('*')` expands the introspected DB names into property names, so a - * column renamed `created_at → createdAt` and a distinct plaintext column - * literally named `createdAt` both emit the token `createdAt`, which - * `addJsonbCastsV3` turns into `createdAt:created_at::jsonb` — twice. PostgREST - * returns the encrypted column under that key and the plaintext one is never - * selected, silently yielding the wrong value for a field the row type - * guarantees. - * - * Nothing downstream can disambiguate the two, and `EncryptedTable.build()`'s - * duplicate check only fires when two BUILDERS share a `getName()`. Refuse to - * construct instead. - */ -function assertNoPropertyDbNameCollision( - tableName: string, - propToDb: Record<string, string>, - allColumns: string[] | null, -): void { - if (!allColumns) return - const dbNames = new Set(allColumns) - - for (const [property, dbName] of Object.entries(propToDb)) { - if (property === dbName) continue - if (!dbNames.has(property)) continue - throw new Error( - `[supabase v3]: property "${property}" on table "${tableName}" renames DB column "${dbName}", but "${property}" is also a distinct column in the database — the two collide in select('*'). Rename the property, or drop the declared rename.`, - ) - } -} - -/** - * EQL v3 dialect of {@link EncryptedQueryBuilderImpl} for native concrete-domain - * columns (`public.*` type domains, `eql_v3` operators). The query mechanism is - * v2's — direct EQL operators over PostgREST — with four narrow forks: - * - * - **Column recognition / naming** — v3 columns are `EncryptedV3Column` - * builders and may map a JS property name to a different DB column name - * (`buildColumnKeyMap`). Filters, select casts, and mutations resolve - * property → DB name; select casts alias the DB column back to the property - * (`prop:db_name::jsonb`) so result rows keep property keys. - * - **Mutation encoding** — the raw encrypted payload object is sent (the - * `public.*` domains are `DOMAIN … AS jsonb`), not v2's `{ data: … }` - * composite wrap. - * - **Query-term encoding** — scalar equality/range filters use the FULL - * storage envelope from `encrypt()`, serialized as jsonb text. - * - * NOT because narrowed terms fail the domain CHECK: the bundle defines a - * `public.<domain>_query` companion for each storage domain, whose CHECK - * requires `NOT (VALUE ? 'c')` — i.e. it accepts exactly the no-ciphertext - * shape `encryptQuery` produces. Those domains are simply unreachable from - * here. PostgREST has no syntax to cast a filter VALUE, and an uncast literal - * is ambiguous between the `_query` and `jsonb` `@>`/`=` overloads (42725 — - * the bundle says so itself, see `cipherstash-encrypt-v3.sql`, the - * `_query_types.sql` note). The reachable overload is the `jsonb` one, whose - * body coerces its operand to the STORAGE domain, which does require `c`. - * (protect-ffi can mint narrowed `eql_v3.query_<name>` operands via - * `encryptQuery`, but with no way to cast a PostgREST filter value they - * stay unreachable from this adapter.) - * - * The full envelope satisfies scalar storage-domain CHECKs by construction, - * and equality/range operators extract the term they need. - * - * EQL 3.0.2 removed the storage/jsonb escape hatch for free-text and JSON - * operators: those now require typed query-domain operands. The factory reads - * the installed EQL version and this builder fails those operators before - * encryption, so a decryptable storage envelope never enters a GET URL. - * - **Legacy `matches`, not `like`/`ilike`/`contains`** — on pre-3.0.2 EQL, - * encrypted free-text search is - * FUZZY BLOOM TOKEN MATCHING, not containment: the bundle declares `@>` on each - * match domain (`CREATE OPERATOR @> … FUNCTION = eql_v3.matches`, the SQL - * function's name), whose body is `match_term(a) @> match_term(b)` — `smallint[]` - * containment of the two bloom filters. It is order- and multiplicity- - * insensitive and one-sided (a `true` may be a false positive). PostgREST - * reaches it as `cs`. The operator is named `matches` to signal that; `contains` - * is reserved for exact (native) containment on plaintext columns. - * - * Match is tokenized + downcased, so `%` is NOT a wildcard. `like`/`ilike` on an - * encrypted column are delegated to `matches` as an APPROXIMATE compatibility - * shim (surrounding `%` stripped, internal `%`/`_` rejected, one warning) and - * pass through as real SQL LIKE on a plaintext column. - * - * Substrings DO match: the needle blooms to its own trigrams, and containment - * holds whenever every one of them is present in the stored value's bloom — - * i.e. for any substring of at least `token_length` (3) characters. Shorter - * needles bloom to nothing (`bf @> '{}'` is true for every row) and are - * rejected up front by `matchNeedleError`, not answered. - * - * Decrypted rows additionally get `Date` reconstruction from the - * encrypt-config `cast_as`, mirroring the typed v3 client. - */ -export class EncryptedQueryBuilderV3Impl< - T extends Record<string, unknown> = Record<string, unknown>, -> extends EncryptedQueryBuilderImpl<T> { - private v3Table: AnyV3Table - /** JS property name → DB column name, for every encrypted column. */ - private propToDb: Record<string, string> - /** DB column name → JS property name — the inverse of {@link propToDb}, used - * to expand `select('*')` back into property names. Null prototype: a DB - * column literally named `constructor` / `toString` would otherwise resolve - * to an inherited `Object.prototype` member and be emitted as a select token. */ - private dbToProp: Record<string, string> - /** Built column schemas keyed by DB column name (for `cast_as`). */ - private columnSchemas: Record<string, ColumnSchema> - /** Column builders keyed by BOTH property name and DB name. */ - private v3Columns: Record<string, V3ColumnLike> - /** EQL 3.0.2+ requires query-domain casts PostgREST cannot express. */ - private queryDomainsRequired: boolean - - constructor( - tableName: string, - table: AnyV3Table, - encryptionClient: EncryptionClient, - supabaseClient: SupabaseClientLike, - allColumns: string[] | null = null, - queryDomainsRequired = false, - ) { - super( - tableName, - // The base class only ever calls BuildableTable members on the schema - // (build / encryptModel plumbing); every v2-specific behaviour is - // overridden below. - table as unknown as EncryptedTable<EncryptedTableColumn>, - encryptionClient, - supabaseClient, - allColumns, - ) - - this.v3Table = table - this.queryDomainsRequired = queryDomainsRequired - this.propToDb = table.buildColumnKeyMap() - this.columnSchemas = table.build().columns - - this.dbToProp = Object.create(null) as Record<string, string> - for (const [property, dbName] of Object.entries(this.propToDb)) { - this.dbToProp[dbName] = property - } - - assertNoPropertyDbNameCollision(tableName, this.propToDb, allColumns) - - // Null-prototype: keyed by DB column names, and `validateTransforms` reads - // it without an own-key guard — an inherited `constructor`/`toString` would - // otherwise resolve truthy for a plaintext column of that name. - this.v3Columns = Object.create(null) as Record<string, V3ColumnLike> - for (const [property, builder] of Object.entries(table.columnBuilders)) { - if (builder instanceof EncryptedV3Column) { - const col = builder as unknown as V3ColumnLike - this.v3Columns[property] = col - this.v3Columns[col.getName()] = col - } - } - - // The base class derives encrypted column names from build(), which v3 - // keys by DB name. Filters and select strings address columns by JS - // property name, so recognition must cover both. - this.encryptedColumnNames = Object.keys(this.v3Columns) - } - - // --------------------------------------------------------------------------- - // Dialect overrides - // --------------------------------------------------------------------------- - - protected override getColumnMap(): Record<string, BuildableQueryColumn> { - return this.v3Columns as unknown as Record<string, BuildableQueryColumn> - } - - /** Resolve a JS property name to its DB column name. `Object.hasOwn` guards - * the inherited-member hazard described on {@link EncryptedTable.buildColumnKeyMap}. */ - private dbNameFor(name: string): string { - return Object.hasOwn(this.propToDb, name) ? this.propToDb[name] : name - } - - protected override filterColumnName(column: string): DbName { - return this.dbNameFor(column) as DbName - } - - /** - * `ORDER BY` on an OPE-backed column is supported; on every other encrypted - * column it is rejected. - * - * A bare `ORDER BY col` IS wrong. The `*_ord` domains are - * `CREATE DOMAIN … AS jsonb`, and the bundle declares no btree operator class - * on any domain — it actively lints against one (`domain_opclass`), because an - * opclass on a domain bypasses operator resolution. So the sort resolves - * through jsonb's default `jsonb_cmp` and compares the envelope's keys in - * storage order, starting at the random ciphertext `c`. No error, and a - * stable, meaningless row order. (Measured: over 10 rows it returns - * `r00,r04,r08,r01,…` where the plaintext order is `r00..r09`.) - * - * But the correct sort key is reachable without a function call. `eql_v3.ord_term` - * returns the domain's `op` term, and OPE is order-preserving by construction: - * ordering by the term reproduces the plaintext order. PostgREST cannot emit - * `ORDER BY eql_v3.ord_term(col)`, but it CAN emit a jsonb path — - * `order=col->op.asc` — which selects exactly that term. Measured against a - * live PostgREST: `order=amount->op.asc` and `.desc` both reproduce the - * plaintext order for `integer_ord` and `text_search`, over 10 rows. - * - * So the guard is on the ordering FLAVOUR, not on encryption: - * - * - `ope` present → order by `col->op`. Every plain `_ord` domain, plus - * `text_ord` and `text_search`. - * - `ore` present → reject. The `ob` term is an array of ORE blocks whose - * comparison needs the superuser-only opclass; a jsonb-path sort over it is - * meaningless. (Such a column cannot hold data on managed Postgres at all: - * its domain CHECK raises `ore_domain_unavailable`.) - * - neither → reject. Storage-only, equality-only and match-only columns - * carry no ordering term to sort by. - * - * A column absent from {@link v3Columns} is a plaintext passthrough and orders - * normally. This runtime guard is the only protection the untyped - * (no-`schemas`) surface has. - */ - protected override validateTransforms(): void { - for (const t of this.transforms) { - if (t.kind !== 'order') continue - const column = this.v3Columns[t.column] - if (!column) continue - - const indexes = this.columnSchemas[column.getName()]?.indexes - if (indexes?.ope) continue - - const reason = indexes?.ore - ? 'its ORE ordering term (`ob`) needs the superuser-only ORE operator class, which PostgREST cannot reach through a jsonb path' - : 'it carries no ordering term to sort by' - - throw new Error( - `[supabase v3]: cannot order by encrypted column "${column.getName()}" (${column.getEqlType()}) — ${reason}. ` + - 'Order by a plaintext column, or use an OPE-backed ordering domain ' + - '(`*_ord`, `text_ord`, `text_search`), or use the EQL v3 Drizzle integration.', - ) - } - } - - /** - * Encrypted ordering columns sort by their `op` term, not by the envelope. - * - * `order=col->op` is the one ordering expression PostgREST can emit that - * reaches the OPE term. It must NOT leak into filters — those compare whole - * envelopes through the `eql_v3.*` operators — which is why this is its own - * seam rather than a change to `filterColumnName`. - * - * The canonical EQL form is `ORDER BY eql_v3.ord_term(col)`, which returns - * `eql_v3_internal.ope_cllw` — a domain over `bytea`, ordered by the native - * btree. PostgREST cannot call a function, so it orders the `op` term where it - * sits, inside the envelope. The two agree because the term is what - * `ord_term()` returns. - * - * `->` (jsonb) rather than `->>` (text) keeps the comparison on the typed - * value. Note this does NOT avoid the database collation: Postgres compares - * jsonb strings with `varstr_cmp` under the default collation, exactly as it - * does text. What makes the ordering collation-independent is the term itself - * — fixed-width lowercase hex (`[0-9a-f]`, 130 chars for `integer_ord`, 82 for - * `text_search`) — and every collation orders digits before letters and hex - * letters among themselves. `match-bloom`'s sibling assertion pins that shape. - * - * This runs at column-name-mapping time (`transformToDbSpace`), BEFORE - * `buildAndExecuteQuery` calls `validateTransforms`. For an encrypted column - * with no `ope` index it therefore returns a bare `dbName` here — a name that - * would sort by `jsonb_cmp` over the ciphertext if it reached PostgREST — but - * it never does: `validateTransforms` throws (with a domain-specific reason) - * before the query executes, so the bare name is only ever an intermediate - * value on a request that is about to be rejected. - */ - protected override orderColumnName(column: string): DbName { - const dbName = this.dbNameFor(column) - const encrypted = this.v3Columns[column] - if (!encrypted) return dbName as DbName - - return ( - this.columnSchemas[dbName]?.indexes?.ope ? `${dbName}->op` : dbName - ) as DbName - } - - /** - * Resolve a raw `.filter()` operator to the capability it exercises. Unlike - * v2, a supported v3 operand is a full storage envelope, so `queryType` - * never selects a narrowing — it only tells {@link encryptCollectedTerms} - * which capability to demand of the column. Getting it wrong therefore - * produces a wrong accept/reject, not a wrong ciphertext: the base class's - * `'equality'` default rejects `.filter('bio', 'cs', …)` on a - * `public.eql_v3_text_match` column, the one query that column can answer. - * - * Unknown operators throw rather than silently defaulting to equality, which - * would encrypt a term the column may not even be able to compare. - */ - protected override queryTypeForRawOp(operator: string): QueryTypeName { - switch (operator) { - case 'cs': - return 'freeTextSearch' - case 'gt': - case 'gte': - case 'lt': - case 'lte': - return 'orderAndRange' - case 'eq': - case 'neq': - case 'in': - case 'is': - return 'equality' - default: - throw new Error( - `[supabase v3]: unsupported raw filter operator "${operator}" on an encrypted column`, - ) - } - } - - protected override buildSelectString(): DbSelect | null { - if (this.selectColumns === null) return null - return addJsonbCastsV3(this.selectColumns, this.propToDb) - } - - /** - * Expand the introspected column list (DB names) into JS property names. - * - * Load-bearing for `select('*')` on a DECLARED table that renames a column. - * `addJsonbCastsV3` only emits the `prop:db_name::jsonb` alias — the thing - * that makes PostgREST return the column under its property name — when the - * token it sees is a property name. Feeding it the raw DB name instead takes - * the unaliased `dbNames.has(...)` branch, so the row comes back keyed - * `created_at` while the declared row type promises `createdAt`, silently - * yielding `undefined` for a field TypeScript guarantees. - * - * A DB column with no encrypted builder (plaintext passthrough, and every - * synthesized column, where property == DB name) maps to itself. - */ - protected override expandAllColumns(columns: string[]): string[] { - return columns.map((dbName) => - Object.hasOwn(this.dbToProp, dbName) ? this.dbToProp[dbName] : dbName, - ) - } - - /** v3 domains are plain jsonb — send the raw payload, keyed by DB name. */ - protected override transformEncryptedMutationModel( - model: Record<string, unknown>, - ): Record<string, unknown> { - const out: Record<string, unknown> = Object.create(null) - for (const [key, value] of Object.entries(model)) { - out[this.dbNameFor(key)] = value - } - return out - } - - protected override transformEncryptedMutationModels( - models: Record<string, unknown>[], - ): Record<string, unknown>[] { - return models.map((model) => this.transformEncryptedMutationModel(model)) - } - - /** - * Validate a term's query type against its column's declared capabilities. - * Pure validation: `encrypt`/`bulkEncrypt` never receive the query type. On - * EQL 3.0.2+, free-text/JSON terms are rejected before this storage-encryption - * path can place ciphertext in a GET URL. - */ - private assertTermQueryable(term: ScalarQueryTerm): V3ColumnLike { - const column = term.column as unknown as V3ColumnLike - let queryType = term.queryType ?? 'equality' - const capabilities = column.getQueryCapabilities() - - // The `cs` wire operator is capability-overloaded: bloom free-text on a - // match/search TEXT column, encrypted ste_vec containment on a `types.Json` - // DOCUMENT column. Both arrive here as `freeTextSearch` (contains/matches/ - // raw `cs` all map there); resolve to the capability the column actually - // carries. The two are mutually exclusive by construction, so this can - // never reinterpret a real free-text column. - if ( - queryType === 'freeTextSearch' && - !capabilities.freeTextSearch && - capabilities.searchableJson - ) { - queryType = 'searchableJson' - } - - if ( - queryType !== 'equality' && - queryType !== 'orderAndRange' && - queryType !== 'freeTextSearch' && - queryType !== 'searchableJson' - ) { - throw new Error( - `[supabase v3]: query type "${queryType}" is not supported on EQL v3 columns`, - ) - } - - if (!capabilities[queryType]) { - throw new Error( - `[supabase v3]: column "${column.getName()}" (${column.getEqlType()}) does not support ${queryType} queries — declare the column with a domain that carries that capability`, - ) - } - - if (queryType === 'freeTextSearch' || queryType === 'searchableJson') { - // This is the common boundary for every spelling that collects an - // encrypted match/containment term: matches(), contains(), not(), raw - // filter(), and both forms of or(). Method-level checks provide earlier - // errors for the direct helpers, but cannot cover the inherited raw - // filter paths on their own. - this.assertPostgrestCanQueryEncryptedOperator('filter', column.getName()) - } - - if (queryType === 'searchableJson') { - // THE single enforced operand boundary for encrypted-JSON containment. - // Terms reach this resolver from every spelling — contains(), raw - // .filter(col,'cs',…), not(col,'contains'|'matches',…), and .or() - // string/structured conditions — and only contains() has a method-level - // guard. Without this check a raw string (e.g. a free-text term ported - // from a text column, or an .or() condition value, which is always a - // string) would be storage-encrypted as a JSON SCALAR and silently match - // nothing; pre-#650 every such spelling failed loudly on capability. - assertJsonContainmentOperand(column.getName(), term.value) - } - - // Free-text (bloom) needle floor. A needle shorter than the tokenizer's - // token_length produces NO tokens, so `bf @> '{}'` holds for every row and - // the query would silently return (and the caller decrypt) the whole table - // — a fail-open over-exposure. Reject it up front, mirroring the Drizzle v3 - // adapter (matchNeedleError) so both first-party surfaces guard identically. - // JSON containment terms (searchableJson) are validated separately above. - if (queryType === 'freeTextSearch') { - const match = column.build().indexes?.match - const reason = match ? matchNeedleError(term.value, match) : undefined - if (reason) { - throw new Error( - `[supabase v3]: cannot search column "${column.getName()}": ${reason}`, - ) - } - } - - return column - } - - private encryptionFailure(message: string, cause?: EncryptionError): never { - logger.error( - `Supabase: failed to encrypt query terms for table "${this.tableName}"`, - ) - // Most callers pass the operation's own `EncryptionError`; the contract- - // violation cases (bulk length mismatch, null envelope) have none, so - // synthesize one — a broken query encryption is still an encryption failure, - // and callers branch on `error.encryptionError` regardless. - throw new EncryptionFailedError( - `Failed to encrypt query terms: ${message}`, - cause ?? { type: EncryptionErrorTypes.EncryptionError, message }, - ) - } - - /** - * Encrypt every filter operand as a full storage envelope, serialized to jsonb - * text for the PostgREST filter value. - * - * Terms are grouped by column and each group takes ONE `bulkEncrypt` crossing. - * `in(col, [a, b, c])` collects one term per element (the list must never be - * encrypted whole), so encrypting per term spent N ZeroKMS/FFI round-trips - * where one would do. `bulkEncrypt` carries a single `{table, column}` for the - * whole payload, so the grouping is mandatory, not an optimisation: one bulk - * call over a mixed-column term array would stamp one column onto every - * plaintext. Results are scattered back onto the terms' original indices, - * which is the contract `termMap` downstream relies on. - * - * Mirrors `eql/v3/drizzle/operators.ts` `encryptOperands` — same batching - * contract, same length assertion, same fallback. Kept separate because that - * one encrypts a single-column operand list and returns `SQL[]`, while this - * must group a multi-column term array and preserve positions. - */ - protected override async encryptCollectedTerms( - terms: ScalarQueryTerm[], - ): Promise<EncryptedQueryResult[]> { - const groups = new Map< - V3ColumnLike, - { indices: number[]; values: ScalarQueryTerm['value'][] } - >() - terms.forEach((term, index) => { - const column = this.assertTermQueryable(term) - const group = groups.get(column) ?? { indices: [], values: [] } - group.indices.push(index) - group.values.push(term.value) - groups.set(column, group) - }) - - const bulkEncrypt = this.encryptionClient.bulkEncrypt?.bind( - this.encryptionClient, - ) - // Each term becomes the `JSON.stringify`'d storage envelope — a `string`, - // which is one arm of `EncryptedQueryResult`. PostgREST cannot cast a filter - // value to the `eql_v3.query_<name>` twins, so v3 sends full envelopes where - // v2 sends `encryptQuery` composite literals; both are `EncryptedQueryResult`. - const results = new Array<EncryptedQueryResult>(terms.length) - - await Promise.all( - Array.from(groups, async ([column, { indices, values }]) => { - const encrypted = bulkEncrypt - ? await this.bulkEncryptGroup(bulkEncrypt, column, values) - : await this.encryptGroupPerTerm(column, values) - - encrypted.forEach((envelope, i) => { - results[indices[i]] = JSON.stringify(envelope) - }) - }), - ) - - return results - } - - /** One FFI crossing for a column's whole operand list. */ - private async bulkEncryptGroup( - bulkEncrypt: NonNullable<EncryptionClient['bulkEncrypt']>, - column: V3ColumnLike, - values: ScalarQueryTerm['value'][], - ): Promise<Array<Encrypted | null>> { - const baseOp = bulkEncrypt( - values.map((plaintext) => ({ plaintext })) as never, - { column, table: this.v3Table } as never, - ) - const op = this.lockContext - ? baseOp.withLockContext(this.lockContext) - : baseOp - if (this.auditConfig) op.audit(this.auditConfig) - - const result = await op - if (result.failure) - this.encryptionFailure(result.failure.message, result.failure) - - // `bulkEncrypt` is position-stable, so a length mismatch means the contract - // was violated. Truncating instead would silently widen an `in` predicate - // (or narrow a `not.in`) to whatever came back. `result.data` is now - // `BulkEncryptedData` — `{ id?, data: Encrypted | null }[]` — not `unknown`. - const encrypted = result.data - if (encrypted.length !== values.length) { - this.encryptionFailure( - `bulk encryption returned ${encrypted.length} terms for ${values.length} values on column "${column.getName()}".`, - ) - } - return encrypted.map((term, i) => { - // `BulkEncryptedData` types the element as `Encrypted | null`. A `null` - // envelope here would be `JSON.stringify`'d to the literal string `"null"` - // and sent as the filter operand — silently matching whatever `"null"` - // encodes to rather than failing. A query term should never encrypt to a - // null envelope, so treat it as a contract violation, not a value. - if (term.data === null) { - this.encryptionFailure( - `bulk encryption returned a null envelope at position ${i} for column "${column.getName()}".`, - ) - } - return term.data - }) - } - - /** Fallback for a client that predates `bulkEncrypt`. */ - private async encryptGroupPerTerm( - column: V3ColumnLike, - values: ScalarQueryTerm['value'][], - ): Promise<Encrypted[]> { - return Promise.all( - values.map(async (value) => { - const baseOp = this.encryptionClient.encrypt(value, { - column, - table: this.v3Table, - }) - const op = this.lockContext - ? baseOp.withLockContext(this.lockContext) - : baseOp - if (this.auditConfig) op.audit(this.auditConfig) - - const result = await op - if (result.failure) { - this.encryptionFailure(result.failure.message, result.failure) - } - return result.data - }), - ) - } - - /** Warn once per (op, column) that a `like`/`ilike` was delegated to `matches`. */ - private static readonly warnedLikeDelegation = new Set<string>() - - /** True when `column` is one of this table's encrypted v3 columns. */ - private isEncryptedV3Column(column: string): boolean { - return Boolean(this.v3Columns[column]) - } - - /** True when `column` is an encrypted `types.Json` document column. */ - private isSearchableJsonColumn(column: string): boolean { - const builder: V3ColumnLike | undefined = this.v3Columns[column] - return Boolean(builder?.getQueryCapabilities().searchableJson) - } - - private assertPostgrestCanQueryEncryptedOperator( - method: string, - column: string, - ): void { - if (!this.queryDomainsRequired) return - throw new Error( - `[supabase v3]: ${method}() on encrypted column "${column}" is unavailable with EQL 3.0.2+: the SQL operator requires an eql_v3.query_* cast that PostgREST cannot express. Use the Drizzle or Prisma Next adapter, or a scoped SQL/RPC function.`, - ) - } - - /** - * `contains` on the v3 surface is EXACT containment: native jsonb/array `@>` - * on a plaintext column, ENCRYPTED ste_vec `@>` on a `types.Json` column (the - * sub-document operand is storage-encrypted whole; every leaf must match at - * its path — #650). On an encrypted match/search TEXT column containment is - * not the operation (that is the fuzzy `matches`), so refuse loudly rather - * than silently emit a bloom match under a name that promises exactness. - */ - override contains(column: string, value: unknown): this { - if (this.isSearchableJsonColumn(column)) { - this.assertPostgrestCanQueryEncryptedOperator('contains', column) - // Same validator the term resolver enforces — failing here just surfaces - // the error at the call site instead of at execution. - assertJsonContainmentOperand(column, value) - return super.contains(column, value) - } - if (this.isEncryptedV3Column(column)) { - throw new Error( - `[supabase v3]: contains() is native (exact) containment and does not apply to encrypted column "${column}". Use matches() for encrypted free-text search.`, - ) - } - return super.contains(column, value) - } - - /** - * `matches` is the encrypted free-text operator: fuzzy bloom-filter token - * matching, one-sided (may false-positive), NOT containment. It requires an - * encrypted match/search column; on a plaintext column, `contains` (native - * `@>`) is what the caller means — and on an encrypted JSON column, - * `contains`/`selectorEq` are (matching a document is containment, not - * free-text). Guarded here because both spellings collect the same - * `freeTextSearch` term, which the capability resolver would otherwise - * silently accept as containment of the raw string. - */ - override matches(column: string, value: unknown): this { - if (this.isSearchableJsonColumn(column)) { - throw new Error( - `[supabase v3]: matches() is encrypted free-text search and does not apply to encrypted JSON column "${column}". Use contains("${column}", subDocument) or selectorEq("${column}", path, value).`, - ) - } - if (!this.isEncryptedV3Column(column)) { - throw new Error( - `[supabase v3]: matches() is encrypted free-text search and requires an encrypted column; "${column}" is not one. Use contains() for native containment.`, - ) - } - this.assertPostgrestCanQueryEncryptedOperator('matches', column) - return super.matches(column, value) - } - - /** - * `not(col, 'contains', …)` on an encrypted TEXT column would negate a fuzzy - * bloom match under the `contains` name — the exact confusion #617 removes — - * because the base `not()` path rewrites the `contains` spelling to the `cs` - * wire operator. Reject it and steer to the `matches` spelling (or the raw - * `cs` operator, which is honest about the wire op). - * - * On an encrypted JSON column negated containment IS the honest exact - * operation (`not.cs` over ste_vec containment — {@link selectorNe} compiles - * to it), so it passes through. Plaintext columns keep native negated - * containment, and every other operator is delegated unchanged. - */ - override not(column: string, operator: string, value: unknown): this { - if ( - operator === 'contains' && - this.isEncryptedV3Column(column) && - !this.isSearchableJsonColumn(column) - ) { - throw new Error( - `[supabase v3]: not("${column}", 'contains', …) does not apply to encrypted column "${column}" — that is fuzzy free-text matching, not containment. Use not("${column}", 'matches', …) or the raw 'cs' operator.`, - ) - } - // Mirror of the matches() guard: a `matches` spelling on a JSON column - // would otherwise resolve to containment (the two share the `cs` wire op), - // silently negating an EXACT operation under a name that promises FUZZY. - if (operator === 'matches' && this.isSearchableJsonColumn(column)) { - throw new Error( - `[supabase v3]: not("${column}", 'matches', …) does not apply to encrypted JSON column "${column}" — matches() is free-text search. Use not("${column}", 'contains', subDocument) or selectorNe("${column}", path, value).`, - ) - } - return super.not(column, operator, value) - } - - /** - * Validate + reconstruct a selector needle: `('$.user.role', 'admin')` → - * `{user: {role: 'admin'}}`. Shared by {@link selectorEq}/{@link selectorNe}; - * throws with column context for a non-JSON column, an invalid path, or a - * non-scalar leaf. - */ - private selectorNeedle( - method: string, - column: string, - path: string, - value: unknown, - ): Record<string, unknown> { - if (!this.isSearchableJsonColumn(column)) { - throw new Error( - `[supabase v3]: ${method}() requires an encrypted JSON (types.Json) column; "${column}" is not one.`, - ) - } - // Selector comparisons compare a scalar LEAF (null included in the shared - // helper's rejection; eq/ne arm — `ordering: false`; - // PostgREST cannot express selector ordering yet, see - // cipherstash/encrypt-query-language#407). - const leafReason = unsupportedLeafReason(value, false) - if (leafReason) { - throw new Error( - `[supabase v3]: ${method}("${column}", "${path}", …): ${leafReason}`, - ) - } - // Stricter than the shared helper (whose Date/bigint arms serve the Drizzle - // surface): a stored JsonDocument leaf is a JSON scalar, so a Date/bigint - // needle could never match one — reject with the serialization steer - // instead of running a query that structurally returns nothing. - if ( - typeof value !== 'string' && - typeof value !== 'number' && - typeof value !== 'boolean' - ) { - throw new Error( - `[supabase v3]: ${method}("${column}", "${path}", …): a JSON document leaf is a JSON scalar (string/number/boolean); got ${value instanceof Date ? 'a Date — pass date.toISOString() (or the stored form)' : typeof value}.`, - ) - } - let segments: string[] - try { - segments = parseSelectorSegments(path) - } catch (err) { - throw new Error( - `[supabase v3]: ${method}("${column}", …): ${err instanceof Error ? err.message : String(err)}`, - ) - } - return reconstructSelectorDocument(segments, value) - } - - /** - * Encrypted JSONPath-selector equality: matches rows whose document carries - * exactly `value` at `path`. Equality at a path IS containment of the - * path-shaped needle (`{user: {role: 'admin'}}`), so this compiles to - * {@link contains} — the ste_vec entry at the selector matches on its - * equality/ordering term. Selector ORDERING (`gt`/`lt`/…) is not expressible - * over PostgREST until the bundle grows a needle-comparison overload - * (cipherstash/encrypt-query-language#407); the Drizzle adapter's - * `ops.selector()` supports it today. - */ - selectorEq(column: string, path: string, value: unknown): this { - this.assertPostgrestCanQueryEncryptedOperator('selectorEq', column) - const needle = this.selectorNeedle('selectorEq', column, path, value) - return super.contains(column, needle) - } - - /** - * Encrypted JSONPath-selector inequality: rows whose document does NOT carry - * `value` at `path` — INCLUDING rows where the path is absent AND rows whose - * document column is SQL NULL, matching the Drizzle selector's `ne` (whose - * `OR entry IS NULL` arm covers both absence cases). A bare `not.cs` would - * drop NULL documents under three-valued logic (`NOT (NULL @> x)` is NULL), - * so this compiles to a structured OR: - * `column.is.null, column.not.cs.<needle>` — the containment condition's - * operand is encrypted through the normal or-condition term path. - */ - selectorNe(column: string, path: string, value: unknown): this { - this.assertPostgrestCanQueryEncryptedOperator('selectorNe', column) - const needle = this.selectorNeedle('selectorNe', column, path, value) - return super.or([ - { column, op: 'is', value: null }, - { column, op: 'contains', negate: true, value: needle }, - ]) - } - - /** - * `like`/`ilike` on an ENCRYPTED column are a best-effort compatibility shim, - * delegated to `matches`. EQL v3 free-text search is fuzzy bloom token - * matching, not SQL pattern matching, so the result is APPROXIMATE — matching - * is case-insensitive and one-sided (may false-positive), and anchoring is - * lost. Leading/trailing `%` are stripped; an internal `%` or any `_` cannot be - * approximated by trigram matching and throws. A plaintext column keeps real - * SQL LIKE. - */ - override like(column: string, pattern: string): this { - if (!this.isEncryptedV3Column(column)) return super.like(column, pattern) - return this.matches(column, this.likeNeedle(column, 'like', pattern)) - } - - override ilike(column: string, pattern: string): this { - if (!this.isEncryptedV3Column(column)) return super.ilike(column, pattern) - return this.matches(column, this.likeNeedle(column, 'ilike', pattern)) - } - - /** - * Reduce a SQL LIKE pattern to a fuzzy-match needle, or throw when it cannot be - * approximated. Strips surrounding `%` (prefix/suffix wildcards, which fuzzy - * matching subsumes); an internal `%` or any `_` is unapproximable. Warns once - * per (op, column) that the delegation is approximate. - */ - private likeNeedle(column: string, op: string, pattern: string): string { - const needle = pattern.replace(/^%+/, '').replace(/%+$/, '') - if (needle.includes('%') || pattern.includes('_')) { - throw new Error( - `[supabase v3]: "${op}" pattern "${pattern}" on encrypted column "${column}" has wildcards fuzzy free-text matching cannot honor (an internal "%" or any "_"). Use matches("${column}", term) with a literal search term.`, - ) - } - const key = `${op}:${column}` - if (!EncryptedQueryBuilderV3Impl.warnedLikeDelegation.has(key)) { - EncryptedQueryBuilderV3Impl.warnedLikeDelegation.add(key) - logger.warn( - `[supabase v3]: "${op}" on encrypted column "${column}" is delegated to matches() (fuzzy bloom token search). Results are APPROXIMATE — case-insensitive, one-sided (may false-positive), and wildcards/anchoring are not honored. Call matches() directly to make this explicit.`, - ) - } - return needle - } - - /** - * Encrypted `matches` goes through the bloom-filter `@>`, which the bundle - * declares on the domain as PostgREST's `cs`. The operand is the full storage - * envelope; `eql_v3.matches` (the SQL function) extracts the `bf` array from - * both sides. - * - * Emitted via `filter(col, 'cs', json)` rather than `q.contains(col, json)`: - * postgrest-js's `contains` re-serializes a non-string operand, and our - * operand is already `JSON.stringify`d. Plaintext `contains` (not encrypted) - * falls through to the base's native path. - */ - protected override applyContainsFilter( - q: SupabaseQueryBuilder, - column: DbName, - value: unknown, - wasEncrypted: boolean, - ): SupabaseQueryBuilder { - if (wasEncrypted) { - this.assertPostgrestCanQueryEncryptedOperator('filter', column) - return q.filter(column, 'cs', value) - } - return super.applyContainsFilter(q, column, value, wasEncrypted) - } - - /** - * `.or()` string conditions carry raw PostgREST operators, so a free-text - * condition arrives as `cs` — not a {@link FilterOp}. Resolve it through the - * same table the raw `.filter()` path uses, so `.or('amount.cs.5')` on an - * `integer_ord` column is rejected by the capability guard rather than - * silently encrypted as an equality term. A structured `{ op: 'matches' }` - * condition maps to free-text directly. - */ - protected override queryTypeForOrOp(op: FilterOp): QueryTypeName { - if (op === 'matches') return 'freeTextSearch' - // Structured conditions may carry the `contains` METHOD spelling (the wire - // token becomes `cs` in rebuildOrString). It maps to the same capability - // gate as `cs`; on a JSON column the term resolver then re-types it to - // searchableJson and validates the operand. selectorNe's IS-NULL-inclusive - // or-form relies on this arm. - if (op === 'contains') return 'freeTextSearch' - return this.queryTypeForRawOp(op) - } - - /** Rebuild `Date` values from the encrypt-config `cast_as` (date/timestamp), - * mirroring the typed v3 client's decrypt-model path. */ - protected override postprocessDecryptedRow( - row: Record<string, unknown>, - ): Record<string, unknown> { - // Every key an encrypted column can appear under: the keys this select - // actually produces (including caller-chosen aliases like `ts:createdAt`), - // plus the static property and DB names as a fallback for paths that record - // no select. Aliases win. Derived here from `this.selectColumns` (the row in - // hand) rather than cached from `buildSelectString`, so a reused builder can - // never postprocess a row with a previous operation's stale select map. - const keyToDb: Record<string, string> = Object.assign( - Object.create(null), - this.selectColumns === null - ? undefined - : selectKeyToDbV3(this.selectColumns, this.propToDb), - ) - for (const [property, dbName] of Object.entries(this.propToDb)) { - keyToDb[property] ??= dbName - keyToDb[dbName] ??= dbName - } - - const out: Record<string, unknown> = { ...row } - for (const [key, dbName] of Object.entries(keyToDb)) { - const castAs = this.columnSchemas[dbName]?.cast_as - if (!DATE_LIKE_CAST_SET.has(castAs as string)) continue - const value = out[key] - if (value == null || value instanceof Date) continue - if (typeof value === 'string' || typeof value === 'number') { - out[key] = new Date(value) - } - } - return out - } -} diff --git a/packages/stack-supabase/src/query-builder.ts b/packages/stack-supabase/src/query-builder.ts index 555df6276..d7356a1a5 100644 --- a/packages/stack-supabase/src/query-builder.ts +++ b/packages/stack-supabase/src/query-builder.ts @@ -1,46 +1,34 @@ -import type { JsPlaintext } from '@cipherstash/protect-ffi' import type { AuditConfig } from '@cipherstash/stack/adapter-kit' import { - bulkModelsToEncryptedPgComposites, logger, - modelToEncryptedPgComposites, + parseSelectorSegments, + reconstructSelectorDocument, + unsupportedLeafReason, } from '@cipherstash/stack/adapter-kit' import type { EncryptionClient } from '@cipherstash/stack/encryption' -import type { EncryptionError } from '@cipherstash/stack/errors' +import type { AnyV3Table } from '@cipherstash/stack/eql/v3' import type { LockContextInput } from '@cipherstash/stack/identity' -import type { - EncryptedTable, - EncryptedTableColumn, -} from '@cipherstash/stack/schema' -import { EncryptedColumn } from '@cipherstash/stack/schema' -import type { - BuildableQueryColumn, - EncryptedQueryResult, - QueryTypeName, - ScalarQueryTerm, -} from '@cipherstash/stack/types' +import { ColumnMap } from './column-map' +import { addJsonbCastsV3 } from './helpers' +import { toDbSpace } from './query-dbspace' +import { + assertJsonContainmentOperand, + assertPostgrestCanQueryEncryptedOperator, + type EncryptedFilterState, + type EncryptionContext, + EncryptionFailedError, + encryptFilterValues, +} from './query-encrypt' +import { applyFilters } from './query-filters' +import { encryptMutationData } from './query-mutation' import { - addJsonbCasts, - formatContainmentOperand, - formatInListOperand, - getEncryptedColumnNames, - isEncryptableTerm, - isEncryptedColumn, - mapFilterOpToQueryType, - parseOrString, - rebuildOrString, -} from './helpers' + type DecryptContext, + decryptResults, + type RawSupabaseResult, +} from './query-results' import type { - DbConflictList, - DbFilterString, - DbMutationOp, - DbMutationOptions, - DbName, - DbPendingOrCondition, - DbPendingOrFilter, DbQuerySpace, DbSelect, - DbTransformOp, EncryptedSupabaseError, EncryptedSupabaseResponse, FilterOp, @@ -51,65 +39,108 @@ import type { PendingOrCondition, PendingOrFilter, PendingRawFilter, + RecordedOps, ResultMode, SupabaseClientLike, SupabaseQueryBuilder, TransformOp, } from './types' +export { EncryptionFailedError } from './query-encrypt' + +/** Warn once per (op, column) that a `like`/`ilike` was delegated to `matches`. */ +const warnedLikeDelegation = new Set<string>() + /** * A deferred query builder that wraps Supabase's query builder to automatically - * handle encryption and decryption of data. + * handle encryption and decryption of data for native EQL v3 concrete-domain + * columns (`public.*` type domains, `eql_v3` operators). * * All chained operations are recorded synchronously. When the builder is awaited, * it encrypts mutation data, adds `::jsonb` casts, batch-encrypts filter values, * executes the real Supabase query, and decrypts results. + * + * v3 columns are `EncryptedV3Column` builders and may map a JS property name to a + * different DB column name (`buildColumnKeyMap`). Filters, select casts, and + * mutations resolve property → DB name; select casts alias the DB column back to + * the property (`prop:db_name::jsonb`) so result rows keep property keys. The raw + * encrypted payload object is sent on mutations (the `public.*` domains are + * `DOMAIN … AS jsonb`), and scalar equality/range filters use the FULL storage + * envelope from `encrypt()`, serialized as jsonb text. + * + * EQL 3.0.2 removed the storage/jsonb escape hatch for free-text and JSON + * operators: those now require typed query-domain operands PostgREST cannot + * express. The factory reads the installed EQL version and this builder fails + * those operators before encryption, so a decryptable storage envelope never + * enters a GET URL. + * + * Decrypted rows additionally get `Date` reconstruction from the encrypt-config + * `cast_as`, mirroring the typed v3 client. This builder authors and reads EQL + * v3 only: legacy `eql_v2_encrypted` columns are not recognised by introspection, + * so they never enter the encrypt config and are returned as untouched + * passthroughs. Decrypt v2 data with the core `@cipherstash/stack` client. + * + * The pipeline is split across sibling modules — `./column-map` (name and + * capability resolution), `./query-encrypt` (mutation data and filter terms), + * `./query-dbspace` (property → DB space), `./query-filters` (operand + * substitution), `./query-results` (decryption) — and orchestrated by + * {@link execute} below. */ export class EncryptedQueryBuilderImpl< - T extends Record<string, unknown> = Record<string, unknown>, + T extends object = Record<string, unknown>, + /** The shape this builder awaits to. `T[]` normally; narrowed to `T` by + * {@link single}/{@link maybeSingle}, which return ONE row. Carried as a + * parameter so the promise cannot keep advertising `T[]` after the runtime + * has been switched to single-row mode. */ + TData = T[], > { - protected tableName: string - protected schema: EncryptedTable<EncryptedTableColumn> - protected encryptionClient: EncryptionClient - protected supabaseClient: SupabaseClientLike - protected encryptedColumnNames: string[] + private tableName: string + private table: AnyV3Table + private encryptionClient: EncryptionClient + private supabaseClient: SupabaseClientLike + /** Name and capability resolution for this table's columns. */ + private columns: ColumnMap /** All column names for the table (encrypted + plaintext), in ordinal order, * used to expand `select('*')`. `null` when the caller supplied no column - * list (v2, or a v3 client that could not introspect). */ - protected allColumns: string[] | null = null + * list (a v3 client that could not introspect). */ + private allColumns: string[] | null = null + /** EQL 3.0.2+ requires query-domain casts PostgREST cannot express. */ + private queryDomainsRequired: boolean // Recorded operations - protected mutation: MutationOp | null = null - protected selectColumns: string | null = null - protected selectOptions: + private mutation: MutationOp | null = null + private selectColumns: string | null = null + private selectOptions: | { head?: boolean; count?: 'exact' | 'planned' | 'estimated' } | undefined = undefined - protected filters: PendingFilter[] = [] - protected orFilters: PendingOrFilter[] = [] - protected matchFilters: PendingMatchFilter[] = [] - protected notFilters: PendingNotFilter[] = [] - protected rawFilters: PendingRawFilter[] = [] - protected transforms: TransformOp[] = [] - protected resultMode: ResultMode = 'array' - protected shouldThrowOnError = false + private filters: PendingFilter[] = [] + private orFilters: PendingOrFilter[] = [] + private matchFilters: PendingMatchFilter[] = [] + private notFilters: PendingNotFilter[] = [] + private rawFilters: PendingRawFilter[] = [] + private transforms: TransformOp[] = [] + private resultMode: ResultMode = 'array' + private shouldThrowOnError = false // Encryption-specific state - protected lockContext: LockContextInput | null = null - protected auditConfig: AuditConfig | null = null + private lockContext: LockContextInput | null = null + private auditConfig: AuditConfig | null = null constructor( tableName: string, - schema: EncryptedTable<EncryptedTableColumn>, + table: AnyV3Table, encryptionClient: EncryptionClient, supabaseClient: SupabaseClientLike, allColumns: string[] | null = null, + queryDomainsRequired = false, ) { this.tableName = tableName - this.schema = schema + this.table = table this.encryptionClient = encryptionClient this.supabaseClient = supabaseClient - this.encryptedColumnNames = getEncryptedColumnNames(schema) this.allColumns = allColumns + this.queryDomainsRequired = queryDomainsRequired + this.columns = new ColumnMap(tableName, table, allColumns) } // --------------------------------------------------------------------------- @@ -126,7 +157,9 @@ export class EncryptedQueryBuilderImpl< "encryptedSupabase does not support select('*'). Please list columns explicitly so that encrypted columns can be cast with ::jsonb.", ) } - this.selectColumns = this.expandAllColumns(this.allColumns).join(', ') + this.selectColumns = this.columns + .expandAllColumns(this.allColumns) + .join(', ') } else { this.selectColumns = columns } @@ -134,17 +167,6 @@ export class EncryptedQueryBuilderImpl< return this } - /** - * Turn the introspected column list (DB names) into select tokens. The base - * returns them unchanged — v2 never supplies a column list, so this is dead - * for v2. The v3 dialect overrides it to emit JS property names, which is - * what makes `addJsonbCastsV3` alias a renamed column back to its property - * (`createdAt:created_at::jsonb`) rather than returning it under its DB name. - */ - protected expandAllColumns(columns: string[]): string[] { - return columns - } - insert( data: Partial<T> | Partial<T>[], options?: { @@ -229,28 +251,79 @@ export class EncryptedQueryBuilderImpl< return this } + /** + * `like`/`ilike` on an ENCRYPTED column are a best-effort compatibility shim, + * delegated to `matches`. EQL v3 free-text search is fuzzy bloom token + * matching, not SQL pattern matching, so the result is APPROXIMATE — matching + * is case-insensitive and one-sided (may false-positive), and anchoring is + * lost. Leading/trailing `%` are stripped; an internal `%` or any `_` cannot be + * approximated by trigram matching and throws. A plaintext column keeps real + * SQL LIKE. + */ like(column: string, pattern: string): this { - this.filters.push({ op: 'like', column, value: pattern }) - return this + if (!this.columns.isEncryptedV3Column(column)) { + this.filters.push({ op: 'like', column, value: pattern }) + return this + } + return this.matches(column, this.likeNeedle(column, 'like', pattern)) } ilike(column: string, pattern: string): this { - this.filters.push({ op: 'ilike', column, value: pattern }) - return this + if (!this.columns.isEncryptedV3Column(column)) { + this.filters.push({ op: 'ilike', column, value: pattern }) + return this + } + return this.matches(column, this.likeNeedle(column, 'ilike', pattern)) } + /** + * `contains` on the v3 surface is EXACT containment: native jsonb/array `@>` + * on a plaintext column, ENCRYPTED ste_vec `@>` on a `types.Json` column (the + * sub-document operand is storage-encrypted whole; every leaf must match at + * its path — #650). On an encrypted match/search TEXT column containment is + * not the operation (that is the fuzzy `matches`), so refuse loudly rather + * than silently emit a bloom match under a name that promises exactness. + */ contains(column: string, value: unknown): this { + if (this.columns.isSearchableJsonColumn(column)) { + this.assertPostgrestCanQueryEncrypted('contains', column) + // Same validator the term resolver enforces — failing here just surfaces + // the error at the call site instead of at execution. + assertJsonContainmentOperand(column, value) + this.filters.push({ op: 'contains', column, value }) + return this + } + if (this.columns.isEncryptedV3Column(column)) { + throw new Error( + `[supabase v3]: contains() is native (exact) containment and does not apply to encrypted column "${column}". Use matches() for encrypted free-text search.`, + ) + } this.filters.push({ op: 'contains', column, value }) return this } /** - * Encrypted free-text token match (v3 encrypted columns). Emits the same - * `cs`/`@>` wire operator as `contains`, but on a match-indexed encrypted - * column it is fuzzy bloom-filter token matching, not containment — see the v3 - * builder. The v3 dialect encrypts the operand as a free-text query term. + * `matches` is the encrypted free-text operator: fuzzy bloom-filter token + * matching, one-sided (may false-positive), NOT containment. It requires an + * encrypted match/search column; on a plaintext column, `contains` (native + * `@>`) is what the caller means — and on an encrypted JSON column, + * `contains`/`selectorEq` are (matching a document is containment, not + * free-text). Guarded here because both spellings collect the same + * `freeTextSearch` term, which the capability resolver would otherwise + * silently accept as containment of the raw string. */ matches(column: string, value: unknown): this { + if (this.columns.isSearchableJsonColumn(column)) { + throw new Error( + `[supabase v3]: matches() is encrypted free-text search and does not apply to encrypted JSON column "${column}". Use contains("${column}", subDocument) or selectorEq("${column}", path, value).`, + ) + } + if (!this.columns.isEncryptedV3Column(column)) { + throw new Error( + `[supabase v3]: matches() is encrypted free-text search and requires an encrypted column; "${column}" is not one. Use contains() for native containment.`, + ) + } + this.assertPostgrestCanQueryEncrypted('matches', column) this.filters.push({ op: 'matches', column, value }) return this } @@ -270,7 +343,36 @@ export class EncryptedQueryBuilderImpl< return this } + /** + * `not(col, 'contains', …)` on an encrypted TEXT column would negate a fuzzy + * bloom match under the `contains` name — the exact confusion #617 removes — + * because the `not()` path rewrites the `contains` spelling to the `cs` wire + * operator. Reject it and steer to the `matches` spelling (or the raw `cs` + * operator, which is honest about the wire op). + * + * On an encrypted JSON column negated containment IS the honest exact + * operation (`not.cs` over ste_vec containment — {@link selectorNe} compiles + * to it), so it passes through. Plaintext columns keep native negated + * containment, and every other operator is recorded unchanged. + */ not(column: string, operator: string, value: unknown): this { + if ( + operator === 'contains' && + this.columns.isEncryptedV3Column(column) && + !this.columns.isSearchableJsonColumn(column) + ) { + throw new Error( + `[supabase v3]: not("${column}", 'contains', …) does not apply to encrypted column "${column}" — that is fuzzy free-text matching, not containment. Use not("${column}", 'matches', …) or the raw 'cs' operator.`, + ) + } + // Mirror of the matches() guard: a `matches` spelling on a JSON column + // would otherwise resolve to containment (the two share the `cs` wire op), + // silently negating an EXACT operation under a name that promises FUZZY. + if (operator === 'matches' && this.columns.isSearchableJsonColumn(column)) { + throw new Error( + `[supabase v3]: not("${column}", 'matches', …) does not apply to encrypted JSON column "${column}" — matches() is free-text search. Use not("${column}", 'contains', subDocument) or selectorNe("${column}", path, value).`, + ) + } this.notFilters.push({ column, op: operator as FilterOp, value }) return this } @@ -299,6 +401,41 @@ export class EncryptedQueryBuilderImpl< return this } + /** + * Encrypted JSONPath-selector equality: matches rows whose document carries + * exactly `value` at `path`. Equality at a path IS containment of the + * path-shaped needle (`{user: {role: 'admin'}}`), so this compiles to + * {@link contains} — the ste_vec entry at the selector matches on its + * equality/ordering term. Selector ORDERING (`gt`/`lt`/…) is not expressible + * over PostgREST until the bundle grows a needle-comparison overload + * (cipherstash/encrypt-query-language#407); the Drizzle adapter's + * `ops.selector()` supports it today. + */ + selectorEq(column: string, path: string, value: unknown): this { + this.assertPostgrestCanQueryEncrypted('selectorEq', column) + const needle = this.selectorNeedle('selectorEq', column, path, value) + return this.contains(column, needle) + } + + /** + * Encrypted JSONPath-selector inequality: rows whose document does NOT carry + * `value` at `path` — INCLUDING rows where the path is absent AND rows whose + * document column is SQL NULL, matching the Drizzle selector's `ne` (whose + * `OR entry IS NULL` arm covers both absence cases). A bare `not.cs` would + * drop NULL documents under three-valued logic (`NOT (NULL @> x)` is NULL), + * so this compiles to a structured OR: + * `column.is.null, column.not.cs.<needle>` — the containment condition's + * operand is encrypted through the normal or-condition term path. + */ + selectorNe(column: string, path: string, value: unknown): this { + this.assertPostgrestCanQueryEncrypted('selectorNe', column) + const needle = this.selectorNeedle('selectorNe', column, path, value) + return this.or([ + { column, op: 'is', value: null }, + { column, op: 'contains', negate: true, value: needle }, + ]) + } + // --------------------------------------------------------------------------- // Transform methods (passthrough) // --------------------------------------------------------------------------- @@ -333,16 +470,19 @@ export class EncryptedQueryBuilderImpl< return this } - single(): this { + single(): EncryptedQueryBuilderImpl<T, T> { this.resultMode = 'single' this.transforms.push({ kind: 'single' }) - return this + // Type-level narrowing only; builder state is preserved. `TData` appears in + // `then`/`execute` return positions, so the two instantiations are not + // mutually assignable and `this` cannot be re-typed without an assertion. + return this as unknown as EncryptedQueryBuilderImpl<T, T> } - maybeSingle(): this { + maybeSingle(): EncryptedQueryBuilderImpl<T, T> { this.resultMode = 'maybeSingle' this.transforms.push({ kind: 'maybeSingle' }) - return this + return this as unknown as EncryptedQueryBuilderImpl<T, T> } csv(): this { @@ -361,9 +501,17 @@ export class EncryptedQueryBuilderImpl< return this } - returns<U extends Record<string, unknown>>(): EncryptedQueryBuilderImpl<U> { + /** Re-type the ROW. The awaited SHAPE is preserved: called after + * `single()`/`maybeSingle()` this still awaits one row, not `U[]`. */ + returns<U extends object>(): EncryptedQueryBuilderImpl< + U, + TData extends readonly unknown[] ? U[] : U + > { // Type-level cast only; builder state is preserved - return this as unknown as EncryptedQueryBuilderImpl<U> + return this as unknown as EncryptedQueryBuilderImpl< + U, + TData extends readonly unknown[] ? U[] : U + > } // --------------------------------------------------------------------------- @@ -384,10 +532,10 @@ export class EncryptedQueryBuilderImpl< // PromiseLike implementation (deferred execution) // --------------------------------------------------------------------------- - then<TResult1 = EncryptedSupabaseResponse<T[]>, TResult2 = never>( + then<TResult1 = EncryptedSupabaseResponse<TData>, TResult2 = never>( onfulfilled?: | (( - value: EncryptedSupabaseResponse<T[]>, + value: EncryptedSupabaseResponse<TData>, ) => TResult1 | PromiseLike<TResult1>) | null, onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null, @@ -399,21 +547,23 @@ export class EncryptedQueryBuilderImpl< // Core execution // --------------------------------------------------------------------------- - protected async execute(): Promise<EncryptedSupabaseResponse<T[]>> { + private async execute(): Promise<EncryptedSupabaseResponse<TData>> { try { logger.debug(`Supabase encrypted query on table "${this.tableName}".`) + const ctx = this.encryptionContext() + // 1. Encrypt mutation data - const encryptedMutation = await this.encryptMutationData() + const encryptedMutation = await encryptMutationData(this.mutation, ctx) // 2. Build select string with ::jsonb casts const selectString = this.buildSelectString() // 3. Translate every recorded column name into DB-space, once. - const dbSpace = this.toDbSpace() + const dbSpace = toDbSpace(this.recordedOps(), this.columns) // 4. Batch-encrypt filter values - const encryptedFilters = await this.encryptFilterValues(dbSpace) + const encryptedFilters = await encryptFilterValues(dbSpace, ctx) // 5. Build and execute real Supabase query const result = await this.buildAndExecuteQuery( @@ -424,7 +574,12 @@ export class EncryptedQueryBuilderImpl< ) // 6. Decrypt results - return await this.decryptResults(result) + return await decryptResults<T, TData>(result, { + ...ctx, + selectColumns: this.selectColumns, + resultMode: this.resultMode, + hasMutation: this.mutation !== null, + } satisfies DecryptContext) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.error( @@ -432,10 +587,10 @@ export class EncryptedQueryBuilderImpl< ) // A failure inside any of the encrypt/decrypt steps above is thrown as an - // `EncryptionFailedError` wrapping the operation's `EncryptionError` (or, in - // the v3 dialect, a synthesized one for its contract-violation cases). - // Thread it through so callers can branch on `error.encryptionError`; a plain - // PostgREST/API error is not an `EncryptionFailedError` and leaves it unset. + // `EncryptionFailedError` wrapping the operation's `EncryptionError` (or a + // synthesized one for its contract-violation cases). Thread it through so + // callers can branch on `error.encryptionError`; a plain PostgREST/API + // error is not an `EncryptionFailedError` and leaves it unset. const error: EncryptedSupabaseError = { message, encryptionError: @@ -458,455 +613,47 @@ export class EncryptedQueryBuilderImpl< } } - // --------------------------------------------------------------------------- - // Step 1: Encrypt mutation data - // --------------------------------------------------------------------------- - - protected async encryptMutationData(): Promise< - Record<string, unknown> | Record<string, unknown>[] | null - > { - if (!this.mutation) return null - - if (this.mutation.kind === 'delete') return null - - const data = this.mutation.data - - if (Array.isArray(data)) { - // Bulk encrypt - const baseOp = this.encryptionClient.bulkEncryptModels(data, this.schema) - const op = this.lockContext - ? baseOp.withLockContext(this.lockContext) - : baseOp - if (this.auditConfig) op.audit(this.auditConfig) - - const result = await op - if (result.failure) { - logger.error( - `Supabase: failed to encrypt models for table "${this.tableName}"`, - ) - - throw new EncryptionFailedError( - `Failed to encrypt models: ${result.failure.message}`, - result.failure, - ) - } - - return this.transformEncryptedMutationModels(result.data) - } - - // Single model - const baseOp = this.encryptionClient.encryptModel(data, this.schema) - const op = this.lockContext - ? baseOp.withLockContext(this.lockContext) - : baseOp - if (this.auditConfig) op.audit(this.auditConfig) - - const result = await op - if (result.failure) { - logger.error( - `Supabase: failed to encrypt model for table "${this.tableName}"`, - ) - - throw new EncryptionFailedError( - `Failed to encrypt model: ${result.failure.message}`, - result.failure, - ) + /** The shared slice of builder state every encrypt/decrypt step needs. Built + * per `execute()`, so `lockContext`/`auditConfig` are read at execution time. */ + private encryptionContext(): EncryptionContext { + return { + tableName: this.tableName, + table: this.table, + encryptionClient: this.encryptionClient, + lockContext: this.lockContext, + auditConfig: this.auditConfig, + columns: this.columns, + queryDomainsRequired: this.queryDomainsRequired, } - - return this.transformEncryptedMutationModel(result.data) - } - - /** - * Encode an encrypted model for the Supabase request body. v2 wraps each - * encrypted value in the `{ data: ... }` object expected by the - * `eql_v2_encrypted` composite type. The v3 dialect overrides this — native - * `eql_v3.*` domains are plain jsonb, so the raw payload is sent instead - * (keyed by DB column name). - */ - protected transformEncryptedMutationModel( - model: Record<string, unknown>, - ): Record<string, unknown> { - return modelToEncryptedPgComposites(model) } - protected transformEncryptedMutationModels( - models: Record<string, unknown>[], - ): Record<string, unknown>[] { - return bulkModelsToEncryptedPgComposites(models) + /** The recorded query in property space, for `toDbSpace`. */ + private recordedOps(): RecordedOps { + return { + filters: this.filters, + matchFilters: this.matchFilters, + notFilters: this.notFilters, + rawFilters: this.rawFilters, + orFilters: this.orFilters, + transforms: this.transforms, + mutation: this.mutation, + } } // --------------------------------------------------------------------------- // Step 2: Build select string with casts // --------------------------------------------------------------------------- - protected buildSelectString(): DbSelect | null { + private buildSelectString(): DbSelect | null { if (this.selectColumns === null) return null - return addJsonbCasts(this.selectColumns, this.encryptedColumnNames) + return addJsonbCastsV3(this.selectColumns, this.columns.propToDb) } // --------------------------------------------------------------------------- - // Step 3: Encrypt filter values + // Step 5: Build and execute real Supabase query // --------------------------------------------------------------------------- - protected async encryptFilterValues( - dbSpace: DbQuerySpace, - ): Promise<EncryptedFilterState> { - // Collect all terms that need encryption - const terms: ScalarQueryTerm[] = [] - const termMap: TermMapping[] = [] - - const tableColumns = this.getColumnMap() - - const pushTerm = ( - value: JsPlaintext, - column: ScalarQueryTerm['column'], - queryType: QueryTypeName, - mapping: TermMapping, - ) => { - terms.push({ - value, - column, - table: this.schema, - queryType, - returnType: 'composite-literal', - }) - termMap.push(mapping) - } - - /** - * Collect one term per element of an `in`-list operand. - * - * Element-wise is the only correct encoding: encrypting the array as ONE - * value collapses `(a,b)` into a single ciphertext that matches nothing. A - * null element is SQL NULL and passes through unencrypted; the applier - * restores it by index, which is why the mapping carries `inIndex`. - * - * Shared by the regular-`in`, `not(…,'in',…)` and or-condition paths. They - * drifted apart once already — the `not` path went unfixed while the other - * two encrypted element-wise — so they are kept in lockstep here rather than - * spelled out three times. - */ - const collectInListTerms = ( - op: FilterOp, - values: readonly unknown[], - column: ScalarQueryTerm['column'], - queryType: QueryTypeName, - mappingFor: (inIndex: number) => TermMapping, - ) => { - for (let j = 0; j < values.length; j++) { - if (!isEncryptableTerm(op, values[j])) continue - pushTerm(values[j] as JsPlaintext, column, queryType, mappingFor(j)) - } - } - - // Regular filters - for (let i = 0; i < dbSpace.filters.length; i++) { - const f = dbSpace.filters[i] - if (!isEncryptedColumn(f.column, this.encryptedColumnNames)) continue - - const column = tableColumns[f.column] - if (!column) continue - - if (f.op === 'in' && Array.isArray(f.value)) { - collectInListTerms( - f.op, - f.value, - column, - mapFilterOpToQueryType(f.op), - (inIndex) => ({ source: 'filter', filterIndex: i, inIndex }), - ) - } else if (!isEncryptableTerm(f.op, f.value)) { - // `is` predicate or null operand — forwarded unencrypted. - } else { - pushTerm(f.value as JsPlaintext, column, mapFilterOpToQueryType(f.op), { - source: 'filter', - filterIndex: i, - }) - } - } - - // Match filters - for (let i = 0; i < dbSpace.matchFilters.length; i++) { - const mf = dbSpace.matchFilters[i] - for (const { column: colName, value } of mf.entries) { - if (!isEncryptedColumn(colName, this.encryptedColumnNames)) continue - // `match` carries no operator; equality is implied. - if (!isEncryptableTerm('eq', value)) continue - const column = tableColumns[colName] - if (!column) continue - - pushTerm(value as JsPlaintext, column, 'equality', { - source: 'match', - matchIndex: i, - column: colName, - }) - } - } - - // Not filters - for (let i = 0; i < dbSpace.notFilters.length; i++) { - const nf = dbSpace.notFilters[i] - if (!isEncryptedColumn(nf.column, this.encryptedColumnNames)) continue - if (!isEncryptableTerm(nf.op, nf.value)) continue - const column = tableColumns[nf.column] - if (!column) continue - - if (nf.op === 'in') { - // A PostgREST list literal (`'(a,b)'`) cannot be encrypted element-wise, - // and encrypting it whole matches nothing. Refuse it rather than emit a - // filter that silently returns no rows. - if (!Array.isArray(nf.value)) { - throw new Error( - `not("${nf.column}", "in", …) on an encrypted column requires an array of values, ` + - `not a PostgREST list literal — each element must be encrypted separately`, - ) - } - collectInListTerms( - nf.op, - nf.value, - column, - mapFilterOpToQueryType(nf.op), - (inIndex) => ({ source: 'not', notIndex: i, inIndex }), - ) - continue - } - - pushTerm(nf.value as JsPlaintext, column, mapFilterOpToQueryType(nf.op), { - source: 'not', - notIndex: i, - }) - } - - // Or filters — conditions were parsed once, in `toDbSpace`. The string and - // structured forms differ only in their `source` tag; the encryption rules, - // including the `in`-list split below, are identical. - for (let i = 0; i < dbSpace.orFilters.length; i++) { - const of_ = dbSpace.orFilters[i] - const source = of_.kind === 'string' ? 'or-string' : 'or-structured' - - for (let j = 0; j < of_.conditions.length; j++) { - const cond = of_.conditions[j] - if (!isEncryptedColumn(cond.column, this.encryptedColumnNames)) continue - const column = tableColumns[cond.column] - if (!column) continue - - // `queryTypeForOrOp`, not `mapFilterOpToQueryType`: an or-condition may - // carry a raw PostgREST operator (`cs`), which is not a `FilterOp`. - const queryType = this.queryTypeForOrOp(cond.op) - const mappingFor = (inIndex?: number): TermMapping => ({ - source, - orIndex: i, - conditionIndex: j, - inIndex, - }) - - if (cond.op === 'in' && Array.isArray(cond.value)) { - collectInListTerms(cond.op, cond.value, column, queryType, mappingFor) - continue - } - - if (!isEncryptableTerm(cond.op, cond.value)) continue - pushTerm(cond.value as JsPlaintext, column, queryType, mappingFor()) - } - } - - // Raw filters - for (let i = 0; i < dbSpace.rawFilters.length; i++) { - const rf = dbSpace.rawFilters[i] - if (!isEncryptedColumn(rf.column, this.encryptedColumnNames)) continue - const column = tableColumns[rf.column] - if (!column) continue - - if (rf.operator === 'in') { - // Same contract as the `not(…, 'in', …)` path: a PostgREST list literal - // (`'("a","b")'`) cannot be encrypted element-wise, and encrypting it - // whole matches nothing. Refuse it rather than emit a filter that - // silently returns no rows. - if (!Array.isArray(rf.value)) { - throw new Error( - `filter("${rf.column}", "in", …) on an encrypted column requires an array of values, ` + - `not a PostgREST list literal — each element must be encrypted separately`, - ) - } - collectInListTerms( - 'in', - rf.value, - column, - this.queryTypeForRawOp(rf.operator), - (inIndex) => ({ source: 'raw', rawIndex: i, inIndex }), - ) - continue - } - - if (!isEncryptableTerm(rf.operator, rf.value)) continue - - pushTerm( - rf.value as JsPlaintext, - column, - this.queryTypeForRawOp(rf.operator), - { source: 'raw', rawIndex: i }, - ) - } - - if (terms.length === 0) { - return { encryptedValues: [], termMap: [] } - } - - const encryptedValues = await this.encryptCollectedTerms(terms) - return { encryptedValues, termMap } - } - - /** - * Encrypt the collected filter terms, returning one encoded value per term - * (in order). v2 batch-encrypts via `encryptQuery` with the - * `composite-literal` return type — the `("json")` string the - * `eql_v2_encrypted` composite operators compare. The v3 dialect overrides - * this to produce full-envelope jsonb operands instead. - */ - protected async encryptCollectedTerms( - terms: ScalarQueryTerm[], - ): Promise<EncryptedQueryResult[]> { - // Batch encrypt all terms in one call - const baseOp = this.encryptionClient.encryptQuery(terms) - const op = this.lockContext - ? baseOp.withLockContext(this.lockContext) - : baseOp - if (this.auditConfig) op.audit(this.auditConfig) - - const result = await op - if (result.failure) { - logger.error( - `Supabase: failed to encrypt query terms for table "${this.tableName}"`, - ) - - throw new EncryptionFailedError( - `Failed to encrypt query terms: ${result.failure.message}`, - result.failure, - ) - } - - return result.data - } - - // --------------------------------------------------------------------------- - // Phase boundary: property-space -> DB-space - // --------------------------------------------------------------------------- - - /** - * Translate every recorded column name from JS property space into DB space, - * once. Downstream (`encryptFilterValues`, `applyFilters`, - * `buildAndExecuteQuery`) consumes only the branded result, so a column can - * no longer reach PostgREST untranslated — that is a compile error. - * - * Total: `filterColumnName`, `parseOrString`, and `resolveMutationOptions` - * never throw, so this introduces no new early-throw point and cannot perturb - * the order in which capability errors surface. - * - * Safe to run BEFORE encryption: `getColumnMap()`/`encryptedColumnNames` are - * keyed by both property and DB name in v3 (and property == DB name in v2), - * so column lookup resolves identically either side of the translation, and - * `tableColumns[prop]` is the very same builder object as `tableColumns[db]`. - */ - protected toDbSpace(): DbQuerySpace { - return { - filters: this.filters.map((f) => ({ - ...f, - column: this.filterColumnName(f.column), - })), - matchFilters: this.matchFilters.map((mf) => ({ - entries: Object.entries(mf.query).map(([column, value]) => ({ - column: this.filterColumnName(column), - value, - })), - })), - notFilters: this.notFilters.map((nf) => ({ - ...nf, - column: this.filterColumnName(nf.column), - })), - rawFilters: this.rawFilters.map((rf) => ({ - ...rf, - column: this.filterColumnName(rf.column), - })), - orFilters: this.orFilters.map((of_) => this.orFilterToDbSpace(of_)), - transforms: this.transforms.map((t) => this.transformToDbSpace(t)), - mutation: this.mutation ? this.mutationToDbSpace(this.mutation) : null, - } - } - - /** Column names only. Which conditions were encrypted is never decided here: - * it stays derived at apply time from the substitution maps, so this pass - * never has to agree with the encryption predicate. The operator token is - * settled later still, in `rebuildOrString`, where `contains` becomes `cs` - * for encrypted and plaintext conditions alike. */ - private orFilterToDbSpace(of_: PendingOrFilter): DbPendingOrFilter { - const toDbCondition = (c: PendingOrCondition): DbPendingOrCondition => ({ - ...c, - column: this.filterColumnName(c.column), - }) - - if (of_.kind === 'string') { - return { - kind: 'string', - original: of_.value, - conditions: parseOrString(of_.value).map(toDbCondition), - referencedTable: of_.referencedTable, - } - } - return { kind: 'structured', conditions: of_.conditions.map(toDbCondition) } - } - - /** - * The column expression `order()` sends to PostgREST. Its own seam, separate - * from {@link filterColumnName}: v3 orders an encrypted column by a jsonb path - * into its ordering term, which must not leak into filters. - */ - protected orderColumnName(column: string): DbName { - return this.filterColumnName(column) - } - - private transformToDbSpace(t: TransformOp): DbTransformOp { - switch (t.kind) { - case 'order': - return { ...t, column: this.orderColumnName(t.column) } - // `returns` is in the union but never pushed (`returns()` is a cast). - case 'limit': - case 'range': - case 'single': - case 'maybeSingle': - case 'csv': - case 'abortSignal': - case 'throwOnError': - case 'returns': - return t - default: { - const exhaustive: never = t - return exhaustive - } - } - } - - private mutationToDbSpace(m: MutationOp): DbMutationOp { - switch (m.kind) { - case 'insert': - case 'upsert': - // `resolveMutationOptions` returns the SAME reference when no column - // needed renaming, which v2 relies on. - return { ...m, options: this.resolveMutationOptions(m.options) } - case 'update': - case 'delete': - return m // options carry no column names - default: { - const exhaustive: never = m - return exhaustive - } - } - } - - // --------------------------------------------------------------------------- - // Step 4: Build and execute real Supabase query - // --------------------------------------------------------------------------- - - protected async buildAndExecuteQuery( + private async buildAndExecuteQuery( encryptedMutation: | Record<string, unknown> | Record<string, unknown>[] @@ -946,7 +693,13 @@ export class EncryptedQueryBuilderImpl< } // Apply resolved filters - query = this.applyFilters(query, encryptedFilters, dbSpace) + query = applyFilters( + query, + encryptedFilters, + dbSpace, + this.columns, + this.queryDomainsRequired, + ) // Apply transforms — column names already in DB-space. for (const t of dbSpace.transforms) { @@ -982,652 +735,145 @@ export class EncryptedQueryBuilderImpl< return result } - // --------------------------------------------------------------------------- - // Apply filters with encrypted values substituted - // --------------------------------------------------------------------------- - - protected applyFilters( - query: SupabaseQueryBuilder, - encryptedFilters: EncryptedFilterState, - dbSpace: DbQuerySpace, - ): SupabaseQueryBuilder { - let q = query - - // Build lookup maps for quick access to encrypted values - const filterValueMap = new Map<number, unknown>() - const filterInMap = new Map<string, unknown>() // "filterIndex:inIndex" -> value - const matchValueMap = new Map<string, unknown>() // "matchIndex:column" -> value - const notValueMap = new Map<number, unknown>() - const notInMap = new Map<string, unknown>() // "notIndex:inIndex" -> value - const rawValueMap = new Map<number, unknown>() - const rawInMap = new Map<string, unknown>() // "rawIndex:inIndex" -> value - const orStringConditionMap = new Map<string, unknown>() // "orIndex:condIndex" -> value - const orStructuredConditionMap = new Map<string, unknown>() - - for (let i = 0; i < encryptedFilters.termMap.length; i++) { - const mapping = encryptedFilters.termMap[i] - const encValue = encryptedFilters.encryptedValues[i] - - switch (mapping.source) { - case 'filter': - if (mapping.inIndex !== undefined) { - filterInMap.set( - `${mapping.filterIndex}:${mapping.inIndex}`, - encValue, - ) - } else { - filterValueMap.set(mapping.filterIndex, encValue) - } - break - case 'match': - matchValueMap.set(`${mapping.matchIndex}:${mapping.column}`, encValue) - break - case 'not': - if (mapping.inIndex !== undefined) { - notInMap.set(`${mapping.notIndex}:${mapping.inIndex}`, encValue) - } else { - notValueMap.set(mapping.notIndex, encValue) - } - break - case 'raw': - if (mapping.inIndex !== undefined) { - rawInMap.set(`${mapping.rawIndex}:${mapping.inIndex}`, encValue) - } else { - rawValueMap.set(mapping.rawIndex, encValue) - } - break - // `inIndex` widens the key to address one element of an `in` list, so a - // whole-condition value and a per-element value never collide. - case 'or-string': - orStringConditionMap.set(orKey(mapping), encValue) - break - case 'or-structured': - orStructuredConditionMap.set(orKey(mapping), encValue) - break - } - } - - // Apply regular filters - for (let i = 0; i < dbSpace.filters.length; i++) { - const f = dbSpace.filters[i] - let value = f.value - - if (filterValueMap.has(i)) { - value = filterValueMap.get(i) - } else if (f.op === 'in' && Array.isArray(f.value)) { - // Reconstruct array with encrypted values substituted - value = f.value.map((v, j) => { - const key = `${i}:${j}` - return filterInMap.has(key) ? filterInMap.get(key) : v - }) - } - - const column = f.column - const wasEncrypted = filterValueMap.has(i) - - switch (f.op) { - case 'eq': - q = q.eq(column, value) - break - case 'neq': - q = q.neq(column, value) - break - case 'gt': - q = q.gt(column, value) - break - case 'gte': - q = q.gte(column, value) - break - case 'lt': - q = q.lt(column, value) - break - case 'lte': - q = q.lte(column, value) - break - case 'like': - case 'ilike': - q = this.applyPatternFilter(q, column, f.op, value, wasEncrypted) - break - // `matches` (encrypted free-text) and `contains` (plaintext / encrypted - // JSON) share the `cs`/`@>` wire operator; the operand encoding is the - // same, so both emit through the one containment applier. - case 'contains': - case 'matches': - q = this.applyContainsFilter(q, column, value, wasEncrypted) - break - case 'is': - q = q.is(column, value) - break - case 'in': - // `wasEncrypted` above is false for in-lists: their ciphertexts land - // in `filterInMap`, keyed per element. - q = this.applyInFilter( - q, - column, - value as unknown[], - Array.isArray(f.value) && - f.value.some((_, j) => filterInMap.has(`${i}:${j}`)), - ) - break - } - } - - // Apply match filters - for (let i = 0; i < dbSpace.matchFilters.length; i++) { - const mf = dbSpace.matchFilters[i] - const resolvedQuery: Record<string, unknown> = {} - - for (const { column: colName, value: originalValue } of mf.entries) { - const key = `${i}:${colName}` - resolvedQuery[colName] = matchValueMap.has(key) - ? matchValueMap.get(key) - : originalValue - } - - q = q.match(resolvedQuery) - } - - // Apply not filters - for (let i = 0; i < dbSpace.notFilters.length; i++) { - const nf = dbSpace.notFilters[i] - - if (nf.op === 'in' && Array.isArray(nf.value)) { - const values = nf.value.map((v, j) => - notInMap.has(`${i}:${j}`) ? notInMap.get(`${i}:${j}`) : v, - ) - q = q.not(nf.column, 'in', formatInListOperand(values)) - continue - } - - const wasEncrypted = notValueMap.has(i) - const value = wasEncrypted ? notValueMap.get(i) : nf.value - - // `contains` is a supabase-js METHOD name, not a PostgREST operator, and - // `q.not()` interpolates its operand with `String(value)` — so an array - // arrives brace-less and an object as `[object Object]`. Build the - // containment literal ourselves and emit the `cs` token, exactly as the - // `.or()` path does. A scalar (including the encrypted envelope, already - // serialized) yields `null` and is forwarded untouched. - if (nf.op === 'contains' || nf.op === 'matches') { - const literal = formatContainmentOperand(value) - q = q.not(nf.column, 'cs', literal ?? value) - continue - } - - q = q.not(nf.column, this.notFilterOperator(nf.op, wasEncrypted), value) - } - - // Apply or filters - for (let i = 0; i < dbSpace.orFilters.length; i++) { - const of_ = dbSpace.orFilters[i] - - if (of_.kind === 'string') { - // Already parsed (once) and translated by `toDbSpace`. - const parsed = [...of_.conditions] - - for (let j = 0; j < parsed.length; j++) { - const sub = substituteOrValue(orStringConditionMap, i, j, parsed[j]) - if (sub) { - parsed[j] = { ...parsed[j], value: sub.value } - } - } - - // Rebuild whenever a condition REFERENCES an encrypted column — not - // merely when a value was encrypted. An `is`/null operand on an - // encrypted column encrypts nothing, so keying on "was a value - // substituted" would send that condition down the verbatim path below - // and forward the caller's JS property name to a DB that only knows the - // column's real name. `toDbSpace` has already translated `parsed`. - const referencesEncrypted = parsed.some((c) => - isEncryptedColumn(c.column, this.encryptedColumnNames), - ) - - if (referencesEncrypted) { - q = q.or(rebuildOrString(parsed), { - referencedTable: of_.referencedTable, - }) - } else { - // Every condition names a plaintext column, whose property name IS - // its DB name — nothing to map. Forward the caller's ORIGINAL string - // byte-for-byte: v2 relies on this for nested `and()` and quoted - // values that `parseOrString`/`rebuildOrString` cannot round-trip. - q = q.or(of_.original as DbFilterString, { - referencedTable: of_.referencedTable, - }) - } - } else { - // Structured: convert to string - const conditions = of_.conditions.map((cond, j) => { - const sub = substituteOrValue(orStructuredConditionMap, i, j, cond) - return sub ? { ...cond, value: sub.value } : cond - }) - - q = q.or(rebuildOrString(conditions)) - } - } - - // Apply raw filters - for (let i = 0; i < dbSpace.rawFilters.length; i++) { - const rf = dbSpace.rawFilters[i] - - // An encrypted `in` list was encrypted element-wise; reassemble it into - // the quoted PostgREST list literal, exactly as the `not` path does. A - // plaintext column keeps its operand untouched. - if ( - rf.operator === 'in' && - Array.isArray(rf.value) && - isEncryptedColumn(rf.column, this.encryptedColumnNames) - ) { - const values = rf.value.map((v, j) => - rawInMap.has(`${i}:${j}`) ? rawInMap.get(`${i}:${j}`) : v, - ) - q = q.filter(rf.column, rf.operator, formatInListOperand(values)) - continue - } - - const value = rawValueMap.has(i) ? rawValueMap.get(i) : rf.value - q = q.filter(rf.column, rf.operator, value) - } - - return q - } - - // --------------------------------------------------------------------------- - // Dialect seams — every default preserves the v2 behaviour byte-for-byte. - // The v3 builder (see ./query-builder-v3) overrides these for native - // `eql_v3.*` domain columns. - // --------------------------------------------------------------------------- - /** - * Map a filter's column name to the DB column name PostgREST must see. - * v2 schemas key columns by their DB name already, so this is the identity; - * the v3 dialect resolves a JS property name to its DB name. + * `ORDER BY` on an OPE-backed column is supported; on every other encrypted + * column it is rejected. * - * This is the ONLY place a {@link DbName} is minted. The - * {@link SupabaseQueryBuilder} seam accepts nothing else, so every column - * name reaching PostgREST must pass through here. - */ - protected filterColumnName(column: string): DbName { - return column as DbName - } - - /** - * Resolve the column names carried by a mutation's options. `onConflict` is a - * comma-separated column list, so it needs the same property→DB mapping as a - * filter. Returns the original object when nothing changed, so v2 — where - * {@link filterColumnName} is the identity — passes the caller's reference on - * untouched. - */ - protected resolveMutationOptions< - O extends { onConflict?: string } | undefined, - >(options: O): DbMutationOptions | undefined { - if (!options?.onConflict) return options as DbMutationOptions | undefined - const mapped = options.onConflict - .split(',') - .map((column) => this.filterColumnName(column.trim())) - .join(',') as DbConflictList - return ( - mapped === options.onConflict - ? options - : { ...options, onConflict: mapped } - ) as DbMutationOptions - } - - /** - * Validate the accumulated transforms before the query is built. Called from - * inside {@link execute}'s try, so a throw surfaces as a `status: 500` error - * result (or rethrows under `throwOnError`), matching the filter-path - * capability guard. v2 imposes no constraints. - */ - protected validateTransforms(): void {} - - /** - * The CipherStash query type to encrypt a raw `.filter(column, operator, …)` - * term under. `operator` is an arbitrary PostgREST operator string, not a - * {@link FilterOp}, so it cannot go through `mapFilterOpToQueryType`. + * A bare `ORDER BY col` IS wrong. The `*_ord` domains are + * `CREATE DOMAIN … AS jsonb`, and the bundle declares no btree operator class + * on any domain — it actively lints against one (`domain_opclass`), because an + * opclass on a domain bypasses operator resolution. So the sort resolves + * through jsonb's default `jsonb_cmp` and compares the envelope's keys in + * storage order, starting at the random ciphertext `c`. No error, and a + * stable, meaningless row order. * - * v2 encrypts every raw filter as an equality term. That is wrong — a raw - * `.filter('amount', 'gte', …)` wants an ORE term — but in v2 `queryType` - * selects the `encryptQuery` narrowing, so correcting it changes the - * ciphertext on the wire. Preserved verbatim here and tracked separately; - * the v3 dialect, where `queryType` is only a capability gate, overrides it. - */ - protected queryTypeForRawOp(_operator: string): QueryTypeName { - return 'equality' - } - - /** - * Apply an `in` filter. + * But the correct sort key is reachable without a function call. `eql_v3.ord_term` + * returns the domain's `op` term, and OPE is order-preserving by construction: + * ordering by the term reproduces the plaintext order. PostgREST cannot emit + * `ORDER BY eql_v3.ord_term(col)`, but it CAN emit a jsonb path — + * `order=col->op.asc` — which selects exactly that term. * - * A plaintext list goes to postgrest-js's `in()`, which quotes elements that - * contain `,()`. An ENCRYPTED list cannot: every element is a - * `JSON.stringify`d envelope, and `in()` wraps it in `"…"` without escaping - * the quotes inside it, so PostgREST terminates the value at the envelope's - * first `"`. Emit the operand ourselves and hand it to `filter()`, which - * forwards it verbatim. - */ - protected applyInFilter( - q: SupabaseQueryBuilder, - column: DbName, - values: unknown[], - wasEncrypted: boolean, - ): SupabaseQueryBuilder { - if (!wasEncrypted) return q.in(column, values) - return q.filter(column, 'in', formatInListOperand(values)) - } - - /** - * Apply a `like`/`ilike` filter. v2 relies on the `~~` operator defined on - * `eql_v2_encrypted`; the v3 dialect overrides this for encrypted columns - * because the `eql_v3.*` domains expose free-text match via `@>` - * (PostgREST `cs`) rather than a LIKE operator. - */ - protected applyPatternFilter( - q: SupabaseQueryBuilder, - column: DbName, - op: 'like' | 'ilike', - value: unknown, - _wasEncrypted: boolean, - ): SupabaseQueryBuilder { - return op === 'like' - ? q.like(column, value as string) - : q.ilike(column, value as string) - } - - /** - * Apply a `contains` filter. On a plaintext column this is PostgREST's native - * jsonb/array containment. The v3 dialect overrides it for encrypted columns, - * where `cs` resolves to the `@>` operator the EQL bundle declares on the - * domain, backed by `eql_v3.matches` (bloom-filter containment). + * So the guard is on the ordering FLAVOUR, not on encryption: + * + * - `ope` present → order by `col->op`. Every plain `_ord` domain, plus + * `text_ord` and `text_search`. + * - `ore` present → reject. The `ob` term is an array of ORE blocks whose + * comparison needs the superuser-only opclass; a jsonb-path sort over it is + * meaningless. + * - neither → reject. Storage-only, equality-only and match-only columns + * carry no ordering term to sort by. * - * A structured operand is serialized here rather than by postgrest-js, which - * joins array elements on `,` without quoting them — so `['with,comma']` would - * reach Postgres as two elements. Scalars keep the native path. + * A column with no encrypted builder is a plaintext passthrough and orders + * normally. This runtime guard is the only protection the untyped + * (no-`schemas`) surface has. */ - protected applyContainsFilter( - q: SupabaseQueryBuilder, - column: DbName, - value: unknown, - _wasEncrypted: boolean, - ): SupabaseQueryBuilder { - const literal = formatContainmentOperand(value) - return literal !== null - ? q.filter(column, 'cs', literal) - : q.contains(column, value) - } + private validateTransforms(): void { + for (const t of this.transforms) { + if (t.kind !== 'order') continue + const column = this.columns.encryptedColumn(t.column) + if (!column) continue - /** - * The CipherStash query type for an `.or()` condition's operator on an - * encrypted column. String-form conditions carry raw PostgREST operators - * (`cs`), which are not {@link FilterOp}s; the v3 dialect maps those. - */ - protected queryTypeForOrOp(op: FilterOp): QueryTypeName { - return mapFilterOpToQueryType(op) - } + const indexes = this.columns.schemaFor(column.getName())?.indexes + if (indexes?.ope) continue - /** - * The PostgREST operator to use for a `.not()` filter. Every {@link FilterOp} - * except `contains` spells the same as its PostgREST operator; `contains` is - * handled before this is reached, because it also needs its operand rewritten. - */ - protected notFilterOperator(op: FilterOp, _wasEncrypted: boolean): string { - return op - } + const reason = indexes?.ore + ? 'its ORE ordering term (`ob`) needs the superuser-only ORE operator class, which PostgREST cannot reach through a jsonb path' + : 'it carries no ordering term to sort by' - /** - * Post-process a decrypted result row. The v3 dialect reconstructs `Date` - * values from the encrypt-config `cast_as`; v2 returns rows unchanged. - */ - protected postprocessDecryptedRow( - row: Record<string, unknown>, - ): Record<string, unknown> { - return row + throw new Error( + `[supabase v3]: cannot order by encrypted column "${column.getName()}" (${column.getEqlType()}) — ${reason}. ` + + 'Order by a plaintext column, or use an OPE-backed ordering domain ' + + '(`*_ord`, `text_ord`, `text_search`), or use the EQL v3 Drizzle integration.', + ) + } } // --------------------------------------------------------------------------- - // Step 5: Decrypt results + // Helpers // --------------------------------------------------------------------------- - protected async decryptResults( - result: RawSupabaseResult, - ): Promise<EncryptedSupabaseResponse<T[]>> { - // If there's an error from Supabase, pass it through - if (result.error) { - return { - data: null, - error: { - message: result.error.message, - details: result.error.details, - hint: result.error.hint, - code: result.error.code, - }, - count: result.count ?? null, - status: result.status, - statusText: result.statusText, - } - } - - // No data to decrypt - if (result.data === null || result.data === undefined) { - return { - data: null, - error: null, - count: result.count ?? null, - status: result.status, - statusText: result.statusText, - } - } - - // Determine if we need to decrypt - const hasSelect = this.selectColumns !== null - const hasMutationWithReturning = this.mutation !== null && hasSelect - - if (!hasSelect && !hasMutationWithReturning) { - // No select means no data to decrypt (e.g., insert without .select()) - return { - data: result.data as T[], - error: null, - count: result.count ?? null, - status: result.status, - statusText: result.statusText, - } - } - - // Decrypt based on result mode - if (this.resultMode === 'single' || this.resultMode === 'maybeSingle') { - if (result.data === null) { - return { - data: null, - error: null, - count: result.count ?? null, - status: result.status, - statusText: result.statusText, - } - } + private assertPostgrestCanQueryEncrypted( + method: string, + column: string, + ): void { + assertPostgrestCanQueryEncryptedOperator( + this.queryDomainsRequired, + method, + column, + ) + } - // Single result — decrypt one model - const baseDecryptOp = this.encryptionClient.decryptModel( - result.data as Record<string, unknown>, + /** + * Validate + reconstruct a selector needle: `('$.user.role', 'admin')` → + * `{user: {role: 'admin'}}`. Shared by {@link selectorEq}/{@link selectorNe}; + * throws with column context for a non-JSON column, an invalid path, or a + * non-scalar leaf. + */ + private selectorNeedle( + method: string, + column: string, + path: string, + value: unknown, + ): Record<string, unknown> { + if (!this.columns.isSearchableJsonColumn(column)) { + throw new Error( + `[supabase v3]: ${method}() requires an encrypted JSON (types.Json) column; "${column}" is not one.`, ) - const decryptOp = this.lockContext - ? baseDecryptOp.withLockContext(this.lockContext) - : baseDecryptOp - if (this.auditConfig) decryptOp.audit(this.auditConfig) - - const decrypted = await decryptOp - if (decrypted.failure) { - logger.error( - `Supabase: failed to decrypt model for table "${this.tableName}"`, - ) - - throw new EncryptionFailedError( - `Failed to decrypt model: ${decrypted.failure.message}`, - decrypted.failure, - ) - } - - return { - data: this.postprocessDecryptedRow( - decrypted.data as Record<string, unknown>, - ) as unknown as T[], - error: null, - count: result.count ?? null, - status: result.status, - statusText: result.statusText, - } - } - - // Array result — bulk decrypt - const dataArray = result.data as Record<string, unknown>[] - if (dataArray.length === 0) { - return { - data: [] as unknown as T[], - error: null, - count: result.count ?? null, - status: result.status, - statusText: result.statusText, - } } - - const baseBulkDecryptOp = this.encryptionClient.bulkDecryptModels(dataArray) - const bulkDecryptOp = this.lockContext - ? baseBulkDecryptOp.withLockContext(this.lockContext) - : baseBulkDecryptOp - if (this.auditConfig) bulkDecryptOp.audit(this.auditConfig) - - const decrypted = await bulkDecryptOp - if (decrypted.failure) { - logger.error( - `Supabase: failed to decrypt models for table "${this.tableName}"`, + // Selector comparisons compare a scalar LEAF (null included in the shared + // helper's rejection; eq/ne arm — `ordering: false`; + // PostgREST cannot express selector ordering yet, see + // cipherstash/encrypt-query-language#407). + const leafReason = unsupportedLeafReason(value, false) + if (leafReason) { + throw new Error( + `[supabase v3]: ${method}("${column}", "${path}", …): ${leafReason}`, ) - - throw new EncryptionFailedError( - `Failed to decrypt models: ${decrypted.failure.message}`, - decrypted.failure, + } + // Stricter than the shared helper (whose Date/bigint arms serve the Drizzle + // surface): a stored JsonDocument leaf is a JSON scalar, so a Date/bigint + // needle could never match one — reject with the serialization steer + // instead of running a query that structurally returns nothing. + if ( + typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'boolean' + ) { + throw new Error( + `[supabase v3]: ${method}("${column}", "${path}", …): a JSON document leaf is a JSON scalar (string/number/boolean); got ${value instanceof Date ? 'a Date — pass date.toISOString() (or the stored form)' : typeof value}.`, ) } - - return { - data: decrypted.data.map((row) => - this.postprocessDecryptedRow(row as Record<string, unknown>), - ) as unknown as T[], - error: null, - count: result.count ?? null, - status: result.status, - statusText: result.statusText, + let segments: string[] + try { + segments = parseSelectorSegments(path) + } catch (err) { + throw new Error( + `[supabase v3]: ${method}("${column}", …): ${err instanceof Error ? err.message : String(err)}`, + ) } + return reconstructSelectorDocument(segments, value) } - // --------------------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------------------- - - protected getColumnMap(): Record<string, BuildableQueryColumn> { - const map: Record<string, BuildableQueryColumn> = {} - const schema = this.schema as unknown as Record<string, unknown> - - for (const colName of this.encryptedColumnNames) { - const col = schema[colName] - if (col instanceof EncryptedColumn) { - map[colName] = col - } - } - - return map - } -} - -// --------------------------------------------------------------------------- -// Internal types -// --------------------------------------------------------------------------- - -type TermMapping = - | { source: 'filter'; filterIndex: number; inIndex?: number } - | { source: 'match'; matchIndex: number; column: string } - | { source: 'not'; notIndex: number; inIndex?: number } - | { source: 'raw'; rawIndex: number; inIndex?: number } - | { - source: 'or-string' - orIndex: number - conditionIndex: number - inIndex?: number + /** + * Reduce a SQL LIKE pattern to a fuzzy-match needle, or throw when it cannot be + * approximated. Strips surrounding `%` (prefix/suffix wildcards, which fuzzy + * matching subsumes); an internal `%` or any `_` is unapproximable. Warns once + * per (op, column) that the delegation is approximate. + */ + private likeNeedle(column: string, op: string, pattern: string): string { + const needle = pattern.replace(/^%+/, '').replace(/%+$/, '') + if (needle.includes('%') || pattern.includes('_')) { + throw new Error( + `[supabase v3]: "${op}" pattern "${pattern}" on encrypted column "${column}" has wildcards fuzzy free-text matching cannot honor (an internal "%" or any "_"). Use matches("${column}", term) with a literal search term.`, + ) } - | { - source: 'or-structured' - orIndex: number - conditionIndex: number - inIndex?: number + const key = `${op}:${column}` + if (!warnedLikeDelegation.has(key)) { + warnedLikeDelegation.add(key) + logger.warn( + `[supabase v3]: "${op}" on encrypted column "${column}" is delegated to matches() (fuzzy bloom token search). Results are APPROXIMATE — case-insensitive, one-sided (may false-positive), and wildcards/anchoring are not honored. Call matches() directly to make this explicit.`, + ) } - -type EncryptedFilterState = { - // `EncryptedQueryResult[]`, not `unknown[]` — `encryptCollectedTerms` returns - // that type, and typing the field to match is what lets the restored envelope - // type reach the use site (`encryptedValues[i]`) instead of widening back to - // `unknown` at this boundary. - encryptedValues: EncryptedQueryResult[] - termMap: TermMapping[] -} - -/** Key an `.or()` condition, or one element of its `in` list. */ -function orKey(mapping: { - orIndex: number - conditionIndex: number - inIndex?: number -}): string { - const base = `${mapping.orIndex}:${mapping.conditionIndex}` - return mapping.inIndex === undefined ? base : `${base}:${mapping.inIndex}` -} - -/** - * Substitute encrypted operands back into one `.or()` condition, returning - * `undefined` when nothing was encrypted for it. - * - * An `in` list is reconstructed element-by-element so `formatOrValue` re-emits - * the `(a,b)` list form. Substituting the array as a single value would collapse - * it to one ciphertext that matches nothing. - */ -function substituteOrValue( - map: Map<string, unknown>, - orIndex: number, - conditionIndex: number, - cond: { op: FilterOp; value: unknown }, -): { value: unknown } | undefined { - const whole = orKey({ orIndex, conditionIndex }) - if (map.has(whole)) return { value: map.get(whole) } - - if (cond.op === 'in' && Array.isArray(cond.value)) { - let substituted = false - const value = cond.value.map((element, inIndex) => { - const key = orKey({ orIndex, conditionIndex, inIndex }) - if (!map.has(key)) return element - substituted = true - return map.get(key) - }) - if (substituted) return { value } - } - - return undefined -} - -type RawSupabaseResult = { - data: unknown - error: { - message: string - details?: string - hint?: string - code?: string - } | null - count?: number | null - status: number - statusText: string -} - -export class EncryptionFailedError extends Error { - public encryptionError: EncryptionError - - constructor(message: string, encryptionError: EncryptionError) { - super(message) - this.name = 'EncryptionFailedError' - this.encryptionError = encryptionError + return needle } } diff --git a/packages/stack-supabase/src/query-dbspace.ts b/packages/stack-supabase/src/query-dbspace.ts new file mode 100644 index 000000000..ae68df562 --- /dev/null +++ b/packages/stack-supabase/src/query-dbspace.ts @@ -0,0 +1,141 @@ +import type { ColumnMap } from './column-map' +import { parseOrString } from './helpers' +import type { + DbConflictList, + DbMutationOp, + DbMutationOptions, + DbPendingOrCondition, + DbPendingOrFilter, + DbQuerySpace, + DbTransformOp, + MutationOp, + PendingOrCondition, + PendingOrFilter, + RecordedOps, + TransformOp, +} from './types' + +/** + * Resolve the column names carried by a mutation's options. `onConflict` is a + * comma-separated column list, so it needs the same property→DB mapping as a + * filter. Returns the original object when nothing changed. + */ +export function resolveMutationOptions< + O extends { onConflict?: string } | undefined, +>(options: O, columns: ColumnMap): DbMutationOptions | undefined { + if (!options?.onConflict) return options as DbMutationOptions | undefined + const mapped = options.onConflict + .split(',') + .map((column) => columns.filterColumnName(column.trim())) + .join(',') as DbConflictList + return ( + mapped === options.onConflict ? options : { ...options, onConflict: mapped } + ) as DbMutationOptions +} + +/** Column names only. Which conditions were encrypted is never decided here: + * it stays derived at apply time from the substitution maps, so this pass + * never has to agree with the encryption predicate. The operator token is + * settled later still, in `rebuildOrString`, where `contains` becomes `cs` + * for encrypted and plaintext conditions alike. */ +function orFilterToDbSpace( + of_: PendingOrFilter, + columns: ColumnMap, +): DbPendingOrFilter { + const toDbCondition = (c: PendingOrCondition): DbPendingOrCondition => ({ + ...c, + column: columns.filterColumnName(c.column), + }) + + if (of_.kind === 'string') { + return { + kind: 'string', + original: of_.value, + conditions: parseOrString(of_.value).map(toDbCondition), + referencedTable: of_.referencedTable, + } + } + return { kind: 'structured', conditions: of_.conditions.map(toDbCondition) } +} + +function transformToDbSpace(t: TransformOp, columns: ColumnMap): DbTransformOp { + switch (t.kind) { + case 'order': + return { ...t, column: columns.orderColumnName(t.column) } + // `returns` is in the union but never pushed (`returns()` is a cast). + case 'limit': + case 'range': + case 'single': + case 'maybeSingle': + case 'csv': + case 'abortSignal': + case 'throwOnError': + case 'returns': + return t + default: { + const exhaustive: never = t + return exhaustive + } + } +} + +function mutationToDbSpace(m: MutationOp, columns: ColumnMap): DbMutationOp { + switch (m.kind) { + case 'insert': + case 'upsert': + return { ...m, options: resolveMutationOptions(m.options, columns) } + case 'update': + case 'delete': + return m // options carry no column names + default: { + const exhaustive: never = m + return exhaustive + } + } +} + +/** + * Translate every recorded column name from JS property space into DB space, + * once. Downstream (`encryptFilterValues`, `applyFilters`, + * `buildAndExecuteQuery`) consumes only the branded result, so a column can + * no longer reach PostgREST untranslated — that is a compile error. + * + * Total: `filterColumnName`, `parseOrString`, and `resolveMutationOptions` + * never throw, so this introduces no new early-throw point and cannot perturb + * the order in which capability errors surface. + * + * Safe to run BEFORE encryption: the column map is keyed by both property and + * DB name, so column lookup resolves identically either side of the + * translation, and `tableColumns[prop]` is the very same builder object as + * `tableColumns[db]`. + */ +export function toDbSpace( + recorded: RecordedOps, + columns: ColumnMap, +): DbQuerySpace { + return { + filters: recorded.filters.map((f) => ({ + ...f, + column: columns.filterColumnName(f.column), + })), + matchFilters: recorded.matchFilters.map((mf) => ({ + entries: Object.entries(mf.query).map(([column, value]) => ({ + column: columns.filterColumnName(column), + value, + })), + })), + notFilters: recorded.notFilters.map((nf) => ({ + ...nf, + column: columns.filterColumnName(nf.column), + })), + rawFilters: recorded.rawFilters.map((rf) => ({ + ...rf, + column: columns.filterColumnName(rf.column), + })), + orFilters: recorded.orFilters.map((of_) => orFilterToDbSpace(of_, columns)), + transforms: recorded.transforms.map((t) => transformToDbSpace(t, columns)), + mutation: recorded.mutation + ? mutationToDbSpace(recorded.mutation, columns) + : null, + } +} diff --git a/packages/stack-supabase/src/query-encrypt.ts b/packages/stack-supabase/src/query-encrypt.ts new file mode 100644 index 000000000..ae67e4bfe --- /dev/null +++ b/packages/stack-supabase/src/query-encrypt.ts @@ -0,0 +1,658 @@ +import type { JsPlaintext } from '@cipherstash/protect-ffi' +import type { AuditConfig } from '@cipherstash/stack/adapter-kit' +import { logger, matchNeedleError } from '@cipherstash/stack/adapter-kit' +import type { EncryptionClient } from '@cipherstash/stack/encryption' +import type { AnyV3Table } from '@cipherstash/stack/eql/v3' +import { + type EncryptionError, + EncryptionErrorTypes, +} from '@cipherstash/stack/errors' +import type { LockContextInput } from '@cipherstash/stack/identity' +import type { + Encrypted, + EncryptedQueryResult, + QueryTypeName, + ScalarQueryTerm, +} from '@cipherstash/stack/types' +import type { ColumnMap, V3ColumnLike } from './column-map' +import { + isEncryptableTerm, + isEncryptedColumn, + mapFilterOpToQueryType, +} from './helpers' +import type { DbQuerySpace, FilterOp } from './types' + +export class EncryptionFailedError extends Error { + public encryptionError: EncryptionError + + constructor(message: string, encryptionError: EncryptionError) { + super(message) + this.name = 'EncryptionFailedError' + this.encryptionError = encryptionError + } +} + +export type TermMapping = + | { source: 'filter'; filterIndex: number; inIndex?: number } + | { source: 'match'; matchIndex: number; column: string } + | { source: 'not'; notIndex: number; inIndex?: number } + | { source: 'raw'; rawIndex: number; inIndex?: number } + | { + source: 'or-string' + orIndex: number + conditionIndex: number + inIndex?: number + } + | { + source: 'or-structured' + orIndex: number + conditionIndex: number + inIndex?: number + } + +export type EncryptedFilterState = { + // `EncryptedQueryResult[]`, not `unknown[]` — `encryptCollectedTerms` returns + // that type, and typing the field to match is what lets the restored envelope + // type reach the use site (`encryptedValues[i]`) instead of widening back to + // `unknown` at this boundary. + encryptedValues: EncryptedQueryResult[] + termMap: TermMapping[] +} + +/** + * Everything an encryption step needs from the builder. Assembled per + * `execute()`, so `lockContext`/`auditConfig` are read at execution time — not + * captured when the builder was constructed. + */ +export type EncryptionContext = { + tableName: string + table: AnyV3Table + encryptionClient: EncryptionClient + lockContext: LockContextInput | null + auditConfig: AuditConfig | null + columns: ColumnMap + /** EQL 3.0.2+ requires query-domain casts PostgREST cannot express. */ + queryDomainsRequired: boolean +} + +/** + * Apply the builder's lock context and audit config to a pending operation. + * + * Every encrypt/decrypt crossing in this adapter goes through the same three + * steps, and they must stay identical: an operation that silently skipped the + * lock context would encrypt under the wrong data key. + */ +export function withOpContext<R>( + baseOp: PromiseLike<R> & { + withLockContext( + lockContext: LockContextInput, + ): PromiseLike<R> & { audit(config: AuditConfig): unknown } + audit(config: AuditConfig): unknown + }, + ctx: Pick<EncryptionContext, 'lockContext' | 'auditConfig'>, +): PromiseLike<R> { + const op = ctx.lockContext ? baseOp.withLockContext(ctx.lockContext) : baseOp + if (ctx.auditConfig) op.audit(ctx.auditConfig) + return op +} + +/** + * EQL 3.0.2 removed the storage/jsonb escape hatch for free-text and JSON + * operators: those now require typed query-domain operands PostgREST cannot + * express. Fail before encryption, so a decryptable storage envelope never + * enters a GET URL. + */ +export function assertPostgrestCanQueryEncryptedOperator( + queryDomainsRequired: boolean, + method: string, + column: string, +): void { + if (!queryDomainsRequired) return + throw new Error( + `[supabase v3]: ${method}() on encrypted column "${column}" is unavailable with EQL 3.0.2+: the SQL operator requires an eql_v3.query_* cast that PostgREST cannot express. Use the Drizzle or Prisma Next adapter, or a scoped SQL/RPC function.`, + ) +} + +/** + * Validate an encrypted-JSON containment operand: a NON-EMPTY plain object or a + * non-empty array. Everything else is rejected with an actionable steer: + * + * - Scalars/strings: the caller meant free-text (`matches` on a text column) or + * a selector — a raw JSON string is NOT parsed, by design (parsing would make + * `'{"a":1}'` and `{a:1}` silently different queries on other surfaces). + * - Non-plain objects (`Date`, `Map`, `RegExp`, class instances): these JSON- + * serialize to scalars or `{}` — not the sub-document the caller believes. + * - `{}` and `[]`: jsonb containment holds for EVERY document (`doc @> '{}'`), + * so an accidentally-empty needle would silently return (and decrypt) the + * whole table. The Drizzle adapter rejects the same needle for the same + * reason — the two first-party adapters must agree that this is an error. + */ +export function assertJsonContainmentOperand( + column: string, + value: unknown, +): void { + const isPlainObject = + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + (Object.getPrototypeOf(value) === Object.prototype || + Object.getPrototypeOf(value) === null) + if (!isPlainObject && !Array.isArray(value)) { + // Array.isArray is false on this branch by construction, so the label only + // distinguishes null / non-plain object / scalar. + const got = + value === null + ? 'null' + : typeof value === 'object' + ? (value as object).constructor?.name || 'a non-plain object' + : typeof value + throw new Error( + `[supabase v3]: encrypted JSON containment on column "${column}" takes a sub-document (plain object or array) to match, got ${got}.`, + ) + } + const empty = Array.isArray(value) + ? value.length === 0 + : Object.keys(value as object).length === 0 + if (empty) { + throw new Error( + `[supabase v3]: encrypted JSON containment on column "${column}" cannot take an empty ${Array.isArray(value) ? 'array' : 'object'} needle: it matches every row. Pass a non-empty sub-document, or omit the predicate to select all rows.`, + ) + } +} + +/** + * Resolve a raw `.filter()` operator to the capability it exercises. A + * supported v3 operand is a full storage envelope, so `queryType` never + * selects a narrowing — it only tells {@link assertTermQueryable} which + * capability to demand of the column. + * + * Unknown operators throw rather than silently defaulting to equality, which + * would encrypt a term the column may not even be able to compare. + */ +export function queryTypeForRawOp(operator: string): QueryTypeName { + switch (operator) { + case 'cs': + return 'freeTextSearch' + case 'gt': + case 'gte': + case 'lt': + case 'lte': + return 'orderAndRange' + case 'eq': + case 'neq': + case 'in': + case 'is': + return 'equality' + default: + throw new Error( + `[supabase v3]: unsupported raw filter operator "${operator}" on an encrypted column`, + ) + } +} + +/** + * The CipherStash query type for an `.or()` condition's operator on an + * encrypted column. String-form conditions carry raw PostgREST operators + * (`cs`), which are not {@link FilterOp}s. + */ +export function queryTypeForOrOp(op: FilterOp): QueryTypeName { + if (op === 'matches') return 'freeTextSearch' + // Structured conditions may carry the `contains` METHOD spelling (the wire + // token becomes `cs` in rebuildOrString). It maps to the same capability + // gate as `cs`; on a JSON column the term resolver then re-types it to + // searchableJson and validates the operand. selectorNe's IS-NULL-inclusive + // or-form relies on this arm. + if (op === 'contains') return 'freeTextSearch' + return queryTypeForRawOp(op) +} + +function encryptionFailure( + tableName: string, + message: string, + cause?: EncryptionError, +): never { + logger.error( + `Supabase: failed to encrypt query terms for table "${tableName}"`, + ) + // Most callers pass the operation's own `EncryptionError`; the contract- + // violation cases (bulk length mismatch, null envelope) have none, so + // synthesize one — a broken query encryption is still an encryption failure, + // and callers branch on `error.encryptionError` regardless. + throw new EncryptionFailedError( + `Failed to encrypt query terms: ${message}`, + cause ?? { type: EncryptionErrorTypes.EncryptionError, message }, + ) +} + +// --------------------------------------------------------------------------- +// Step 3: Encrypt filter values +// --------------------------------------------------------------------------- + +export async function encryptFilterValues( + dbSpace: DbQuerySpace, + ctx: EncryptionContext, +): Promise<EncryptedFilterState> { + // Collect all terms that need encryption + const terms: ScalarQueryTerm[] = [] + const termMap: TermMapping[] = [] + + const tableColumns = ctx.columns.queryColumnMap() + const encryptedColumnNames = ctx.columns.encryptedColumnNames + + const pushTerm = ( + value: JsPlaintext, + column: ScalarQueryTerm['column'], + queryType: QueryTypeName, + mapping: TermMapping, + ) => { + terms.push({ + value, + column, + table: ctx.table, + queryType, + }) + termMap.push(mapping) + } + + /** + * Collect one term per element of an `in`-list operand. + * + * Element-wise is the only correct encoding: encrypting the array as ONE + * value collapses `(a,b)` into a single ciphertext that matches nothing. A + * null element is SQL NULL and passes through unencrypted; the applier + * restores it by index, which is why the mapping carries `inIndex`. + * + * Shared by the regular-`in`, `not(…,'in',…)` and or-condition paths. They + * drifted apart once already — the `not` path went unfixed while the other + * two encrypted element-wise — so they are kept in lockstep here rather than + * spelled out three times. + */ + const collectInListTerms = ( + op: FilterOp, + values: readonly unknown[], + column: ScalarQueryTerm['column'], + queryType: QueryTypeName, + mappingFor: (inIndex: number) => TermMapping, + ) => { + for (let j = 0; j < values.length; j++) { + if (!isEncryptableTerm(op, values[j])) continue + pushTerm(values[j] as JsPlaintext, column, queryType, mappingFor(j)) + } + } + + // Regular filters + for (let i = 0; i < dbSpace.filters.length; i++) { + const f = dbSpace.filters[i] + if (!isEncryptedColumn(f.column, encryptedColumnNames)) continue + + const column = tableColumns[f.column] + if (!column) continue + + if (f.op === 'in' && Array.isArray(f.value)) { + collectInListTerms( + f.op, + f.value, + column, + mapFilterOpToQueryType(f.op), + (inIndex) => ({ source: 'filter', filterIndex: i, inIndex }), + ) + } else if (!isEncryptableTerm(f.op, f.value)) { + // `is` predicate or null operand — forwarded unencrypted. + } else { + pushTerm(f.value as JsPlaintext, column, mapFilterOpToQueryType(f.op), { + source: 'filter', + filterIndex: i, + }) + } + } + + // Match filters + for (let i = 0; i < dbSpace.matchFilters.length; i++) { + const mf = dbSpace.matchFilters[i] + for (const { column: colName, value } of mf.entries) { + if (!isEncryptedColumn(colName, encryptedColumnNames)) continue + // `match` carries no operator; equality is implied. + if (!isEncryptableTerm('eq', value)) continue + const column = tableColumns[colName] + if (!column) continue + + pushTerm(value as JsPlaintext, column, 'equality', { + source: 'match', + matchIndex: i, + column: colName, + }) + } + } + + // Not filters + for (let i = 0; i < dbSpace.notFilters.length; i++) { + const nf = dbSpace.notFilters[i] + if (!isEncryptedColumn(nf.column, encryptedColumnNames)) continue + if (!isEncryptableTerm(nf.op, nf.value)) continue + const column = tableColumns[nf.column] + if (!column) continue + + if (nf.op === 'in') { + // A PostgREST list literal (`'(a,b)'`) cannot be encrypted element-wise, + // and encrypting it whole matches nothing. Refuse it rather than emit a + // filter that silently returns no rows. + if (!Array.isArray(nf.value)) { + throw new Error( + `not("${nf.column}", "in", …) on an encrypted column requires an array of values, ` + + `not a PostgREST list literal — each element must be encrypted separately`, + ) + } + collectInListTerms( + nf.op, + nf.value, + column, + mapFilterOpToQueryType(nf.op), + (inIndex) => ({ source: 'not', notIndex: i, inIndex }), + ) + continue + } + + pushTerm(nf.value as JsPlaintext, column, mapFilterOpToQueryType(nf.op), { + source: 'not', + notIndex: i, + }) + } + + // Or filters — conditions were parsed once, in `toDbSpace`. The string and + // structured forms differ only in their `source` tag; the encryption rules, + // including the `in`-list split below, are identical. + for (let i = 0; i < dbSpace.orFilters.length; i++) { + const of_ = dbSpace.orFilters[i] + const source = of_.kind === 'string' ? 'or-string' : 'or-structured' + + for (let j = 0; j < of_.conditions.length; j++) { + const cond = of_.conditions[j] + if (!isEncryptedColumn(cond.column, encryptedColumnNames)) continue + const column = tableColumns[cond.column] + if (!column) continue + + // `queryTypeForOrOp`, not `mapFilterOpToQueryType`: an or-condition may + // carry a raw PostgREST operator (`cs`), which is not a `FilterOp`. + const queryType = queryTypeForOrOp(cond.op) + const mappingFor = (inIndex?: number): TermMapping => ({ + source, + orIndex: i, + conditionIndex: j, + inIndex, + }) + + if (cond.op === 'in' && Array.isArray(cond.value)) { + collectInListTerms(cond.op, cond.value, column, queryType, mappingFor) + continue + } + + if (!isEncryptableTerm(cond.op, cond.value)) continue + pushTerm(cond.value as JsPlaintext, column, queryType, mappingFor()) + } + } + + // Raw filters + for (let i = 0; i < dbSpace.rawFilters.length; i++) { + const rf = dbSpace.rawFilters[i] + if (!isEncryptedColumn(rf.column, encryptedColumnNames)) continue + const column = tableColumns[rf.column] + if (!column) continue + + if (rf.operator === 'in') { + // Same contract as the `not(…, 'in', …)` path: a PostgREST list literal + // (`'("a","b")'`) cannot be encrypted element-wise, and encrypting it + // whole matches nothing. Refuse it rather than emit a filter that + // silently returns no rows. + if (!Array.isArray(rf.value)) { + throw new Error( + `filter("${rf.column}", "in", …) on an encrypted column requires an array of values, ` + + `not a PostgREST list literal — each element must be encrypted separately`, + ) + } + collectInListTerms( + 'in', + rf.value, + column, + queryTypeForRawOp(rf.operator), + (inIndex) => ({ source: 'raw', rawIndex: i, inIndex }), + ) + continue + } + + if (!isEncryptableTerm(rf.operator, rf.value)) continue + + pushTerm(rf.value as JsPlaintext, column, queryTypeForRawOp(rf.operator), { + source: 'raw', + rawIndex: i, + }) + } + + if (terms.length === 0) { + return { encryptedValues: [], termMap: [] } + } + + const encryptedValues = await encryptCollectedTerms(terms, ctx) + return { encryptedValues, termMap } +} + +/** + * Encrypt every filter operand as a full storage envelope, serialized to jsonb + * text for the PostgREST filter value. + * + * Terms are grouped by column and each group takes ONE `bulkEncrypt` crossing. + * `in(col, [a, b, c])` collects one term per element (the list must never be + * encrypted whole), so encrypting per term spent N ZeroKMS/FFI round-trips + * where one would do. `bulkEncrypt` carries a single `{table, column}` for the + * whole payload, so the grouping is mandatory, not an optimisation: one bulk + * call over a mixed-column term array would stamp one column onto every + * plaintext. Results are scattered back onto the terms' original indices, + * which is the contract `termMap` downstream relies on. + * + * Mirrors `eql/v3/drizzle/operators.ts` `encryptOperands` — same batching + * contract, same length assertion, same fallback. Kept separate because that + * one encrypts a single-column operand list and returns `SQL[]`, while this + * must group a multi-column term array and preserve positions. + */ +async function encryptCollectedTerms( + terms: ScalarQueryTerm[], + ctx: EncryptionContext, +): Promise<EncryptedQueryResult[]> { + const groups = new Map< + V3ColumnLike, + { indices: number[]; values: ScalarQueryTerm['value'][] } + >() + terms.forEach((term, index) => { + const column = assertTermQueryable(term, ctx) + const group = groups.get(column) ?? { indices: [], values: [] } + group.indices.push(index) + group.values.push(term.value) + groups.set(column, group) + }) + + const bulkEncrypt = ctx.encryptionClient.bulkEncrypt?.bind( + ctx.encryptionClient, + ) + // Each term becomes the `JSON.stringify`'d storage envelope — a `string`, + // which is one arm of `EncryptedQueryResult`. PostgREST cannot cast a filter + // value to the `eql_v3.query_<name>` twins, so v3 sends full envelopes, + // serialized to jsonb text. + const results = new Array<EncryptedQueryResult>(terms.length) + + await Promise.all( + Array.from(groups, async ([column, { indices, values }]) => { + const encrypted = bulkEncrypt + ? await bulkEncryptGroup(bulkEncrypt, column, values, ctx) + : await encryptGroupPerTerm(column, values, ctx) + + encrypted.forEach((envelope, i) => { + results[indices[i]] = JSON.stringify(envelope) + }) + }), + ) + + return results +} + +/** + * Validate a term's query type against its column's declared capabilities. + * Pure validation: `encrypt`/`bulkEncrypt` never receive the query type. On + * EQL 3.0.2+, free-text/JSON terms are rejected before this storage-encryption + * path can place ciphertext in a GET URL. + * + * Exported for direct testing: no public call path can produce an unsupported + * `queryType` (`mapFilterOpToQueryType`, {@link queryTypeForRawOp} and + * {@link queryTypeForOrOp} are exhaustive), so that backstop is only reachable + * by calling this with a hand-built term. + */ +export function assertTermQueryable( + term: ScalarQueryTerm, + ctx: EncryptionContext, +): V3ColumnLike { + const column = term.column as unknown as V3ColumnLike + let queryType = term.queryType ?? 'equality' + const capabilities = column.getQueryCapabilities() + + // The `cs` wire operator is capability-overloaded: bloom free-text on a + // match/search TEXT column, encrypted ste_vec containment on a `types.Json` + // DOCUMENT column. Both arrive here as `freeTextSearch` (contains/matches/ + // raw `cs` all map there); resolve to the capability the column actually + // carries. The two are mutually exclusive by construction, so this can + // never reinterpret a real free-text column. + if ( + queryType === 'freeTextSearch' && + !capabilities.freeTextSearch && + capabilities.searchableJson + ) { + queryType = 'searchableJson' + } + + if ( + queryType !== 'equality' && + queryType !== 'orderAndRange' && + queryType !== 'freeTextSearch' && + queryType !== 'searchableJson' + ) { + throw new Error( + `[supabase v3]: query type "${queryType}" is not supported on EQL v3 columns`, + ) + } + + if (!capabilities[queryType]) { + throw new Error( + `[supabase v3]: column "${column.getName()}" (${column.getEqlType()}) does not support ${queryType} queries — declare the column with a domain that carries that capability`, + ) + } + + if (queryType === 'freeTextSearch' || queryType === 'searchableJson') { + // This is the common boundary for every spelling that collects an + // encrypted match/containment term: matches(), contains(), not(), raw + // filter(), and both forms of or(). Method-level checks provide earlier + // errors for the direct helpers, but cannot cover the raw filter paths on + // their own. + assertPostgrestCanQueryEncryptedOperator( + ctx.queryDomainsRequired, + 'filter', + column.getName(), + ) + } + + if (queryType === 'searchableJson') { + // THE single enforced operand boundary for encrypted-JSON containment. + // Terms reach this resolver from every spelling — contains(), raw + // .filter(col,'cs',…), not(col,'contains'|'matches',…), and .or() + // string/structured conditions — and only contains() has a method-level + // guard. Without this check a raw string (e.g. a free-text term ported + // from a text column, or an .or() condition value, which is always a + // string) would be storage-encrypted as a JSON SCALAR and silently match + // nothing; pre-#650 every such spelling failed loudly on capability. + assertJsonContainmentOperand(column.getName(), term.value) + } + + // Free-text (bloom) needle floor. A needle shorter than the tokenizer's + // token_length produces NO tokens, so `bf @> '{}'` holds for every row and + // the query would silently return (and the caller decrypt) the whole table + // — a fail-open over-exposure. Reject it up front, mirroring the Drizzle v3 + // adapter (matchNeedleError) so both first-party surfaces guard identically. + // JSON containment terms (searchableJson) are validated separately above. + if (queryType === 'freeTextSearch') { + const match = column.build().indexes?.match + const reason = match ? matchNeedleError(term.value, match) : undefined + if (reason) { + throw new Error( + `[supabase v3]: cannot search column "${column.getName()}": ${reason}`, + ) + } + } + + return column +} + +/** One FFI crossing for a column's whole operand list. */ +async function bulkEncryptGroup( + bulkEncrypt: NonNullable<EncryptionClient['bulkEncrypt']>, + column: V3ColumnLike, + values: ScalarQueryTerm['value'][], + ctx: EncryptionContext, +): Promise<Array<Encrypted | null>> { + const result = await withOpContext( + bulkEncrypt( + values.map((plaintext) => ({ plaintext })) as never, + { + column, + table: ctx.table, + } as never, + ), + ctx, + ) + if (result.failure) + encryptionFailure(ctx.tableName, result.failure.message, result.failure) + + // `bulkEncrypt` is position-stable, so a length mismatch means the contract + // was violated. Truncating instead would silently widen an `in` predicate + // (or narrow a `not.in`) to whatever came back. `result.data` is now + // `BulkEncryptedData` — `{ id?, data: Encrypted | null }[]` — not `unknown`. + const encrypted = result.data + if (encrypted.length !== values.length) { + encryptionFailure( + ctx.tableName, + `bulk encryption returned ${encrypted.length} terms for ${values.length} values on column "${column.getName()}".`, + ) + } + return encrypted.map((term, i) => { + // `BulkEncryptedData` types the element as `Encrypted | null`. A `null` + // envelope here would be `JSON.stringify`'d to the literal string `"null"` + // and sent as the filter operand — silently matching whatever `"null"` + // encodes to rather than failing. A query term should never encrypt to a + // null envelope, so treat it as a contract violation, not a value. + if (term.data === null) { + encryptionFailure( + ctx.tableName, + `bulk encryption returned a null envelope at position ${i} for column "${column.getName()}".`, + ) + } + return term.data + }) +} + +/** Fallback for a client that predates `bulkEncrypt`. */ +async function encryptGroupPerTerm( + column: V3ColumnLike, + values: ScalarQueryTerm['value'][], + ctx: EncryptionContext, +): Promise<Encrypted[]> { + return Promise.all( + values.map(async (value) => { + const result = await withOpContext( + ctx.encryptionClient.encrypt(value, { + column, + table: ctx.table, + }), + ctx, + ) + if (result.failure) { + encryptionFailure(ctx.tableName, result.failure.message, result.failure) + } + return result.data + }), + ) +} diff --git a/packages/stack-supabase/src/query-filters.ts b/packages/stack-supabase/src/query-filters.ts new file mode 100644 index 000000000..fdad1d10a --- /dev/null +++ b/packages/stack-supabase/src/query-filters.ts @@ -0,0 +1,388 @@ +import type { ColumnMap } from './column-map' +import { + formatContainmentOperand, + formatInListOperand, + isEncryptedColumn, + rebuildOrString, +} from './helpers' +import { + assertPostgrestCanQueryEncryptedOperator, + type EncryptedFilterState, +} from './query-encrypt' +import type { + DbFilterString, + DbName, + DbQuerySpace, + FilterOp, + SupabaseQueryBuilder, +} from './types' + +/** Key an `.or()` condition, or one element of its `in` list. */ +function orKey(mapping: { + orIndex: number + conditionIndex: number + inIndex?: number +}): string { + const base = `${mapping.orIndex}:${mapping.conditionIndex}` + return mapping.inIndex === undefined ? base : `${base}:${mapping.inIndex}` +} + +/** + * Substitute encrypted operands back into one `.or()` condition, returning + * `undefined` when nothing was encrypted for it. + * + * An `in` list is reconstructed element-by-element so `formatOrValue` re-emits + * the `(a,b)` list form. Substituting the array as a single value would collapse + * it to one ciphertext that matches nothing. + */ +function substituteOrValue( + map: Map<string, unknown>, + orIndex: number, + conditionIndex: number, + cond: { op: FilterOp; value: unknown }, +): { value: unknown } | undefined { + const whole = orKey({ orIndex, conditionIndex }) + if (map.has(whole)) return { value: map.get(whole) } + + if (cond.op === 'in' && Array.isArray(cond.value)) { + let substituted = false + const value = cond.value.map((element, inIndex) => { + const key = orKey({ orIndex, conditionIndex, inIndex }) + if (!map.has(key)) return element + substituted = true + return map.get(key) + }) + if (substituted) return { value } + } + + return undefined +} + +/** + * Apply an `in` filter. + * + * A plaintext list goes to postgrest-js's `in()`, which quotes elements that + * contain `,()`. An ENCRYPTED list cannot: every element is a + * `JSON.stringify`d envelope, and `in()` wraps it in `"…"` without escaping + * the quotes inside it, so PostgREST terminates the value at the envelope's + * first `"`. Emit the operand ourselves and hand it to `filter()`, which + * forwards it verbatim. + */ +function applyInFilter( + q: SupabaseQueryBuilder, + column: DbName, + values: unknown[], + wasEncrypted: boolean, +): SupabaseQueryBuilder { + if (!wasEncrypted) return q.in(column, values) + return q.filter(column, 'in', formatInListOperand(values)) +} + +/** + * Apply a `like`/`ilike` filter. On an encrypted column `like`/`ilike` were + * rewritten to `matches` at record time, so a `like`/`ilike` pending filter + * only ever names a plaintext column, which keeps real SQL LIKE. + */ +function applyPatternFilter( + q: SupabaseQueryBuilder, + column: DbName, + op: 'like' | 'ilike', + value: unknown, +): SupabaseQueryBuilder { + return op === 'like' + ? q.like(column, value as string) + : q.ilike(column, value as string) +} + +/** + * Apply a `contains` filter. On a plaintext column this is PostgREST's native + * jsonb/array containment. On an encrypted column `cs` resolves to the `@>` + * operator the EQL bundle declares on the domain, backed by `eql_v3.matches` + * (bloom-filter containment) — and the operand is the full storage envelope, + * already `JSON.stringify`d, emitted via `filter(col, 'cs', json)` rather than + * `q.contains` (postgrest-js's `contains` re-serializes a non-string operand). + * + * A structured plaintext operand is serialized here rather than by + * postgrest-js, which joins array elements on `,` without quoting them — so + * `['with,comma']` would reach Postgres as two elements. Scalars keep the + * native path. + */ +function applyContainsFilter( + q: SupabaseQueryBuilder, + column: DbName, + value: unknown, + wasEncrypted: boolean, + queryDomainsRequired: boolean, +): SupabaseQueryBuilder { + if (wasEncrypted) { + assertPostgrestCanQueryEncryptedOperator( + queryDomainsRequired, + 'filter', + column, + ) + return q.filter(column, 'cs', value) + } + const literal = formatContainmentOperand(value) + return literal !== null + ? q.filter(column, 'cs', literal) + : q.contains(column, value) +} + +/** + * Apply every recorded filter to the real Supabase query, substituting the + * encrypted operand wherever one was produced for that position. + */ +export function applyFilters( + query: SupabaseQueryBuilder, + encryptedFilters: EncryptedFilterState, + dbSpace: DbQuerySpace, + columns: ColumnMap, + queryDomainsRequired: boolean, +): SupabaseQueryBuilder { + let q = query + const encryptedColumnNames = columns.encryptedColumnNames + + // Build lookup maps for quick access to encrypted values + const filterValueMap = new Map<number, unknown>() + const filterInMap = new Map<string, unknown>() // "filterIndex:inIndex" -> value + const matchValueMap = new Map<string, unknown>() // "matchIndex:column" -> value + const notValueMap = new Map<number, unknown>() + const notInMap = new Map<string, unknown>() // "notIndex:inIndex" -> value + const rawValueMap = new Map<number, unknown>() + const rawInMap = new Map<string, unknown>() // "rawIndex:inIndex" -> value + const orStringConditionMap = new Map<string, unknown>() // "orIndex:condIndex" -> value + const orStructuredConditionMap = new Map<string, unknown>() + + for (let i = 0; i < encryptedFilters.termMap.length; i++) { + const mapping = encryptedFilters.termMap[i] + const encValue = encryptedFilters.encryptedValues[i] + + switch (mapping.source) { + case 'filter': + if (mapping.inIndex !== undefined) { + filterInMap.set(`${mapping.filterIndex}:${mapping.inIndex}`, encValue) + } else { + filterValueMap.set(mapping.filterIndex, encValue) + } + break + case 'match': + matchValueMap.set(`${mapping.matchIndex}:${mapping.column}`, encValue) + break + case 'not': + if (mapping.inIndex !== undefined) { + notInMap.set(`${mapping.notIndex}:${mapping.inIndex}`, encValue) + } else { + notValueMap.set(mapping.notIndex, encValue) + } + break + case 'raw': + if (mapping.inIndex !== undefined) { + rawInMap.set(`${mapping.rawIndex}:${mapping.inIndex}`, encValue) + } else { + rawValueMap.set(mapping.rawIndex, encValue) + } + break + // `inIndex` widens the key to address one element of an `in` list, so a + // whole-condition value and a per-element value never collide. + case 'or-string': + orStringConditionMap.set(orKey(mapping), encValue) + break + case 'or-structured': + orStructuredConditionMap.set(orKey(mapping), encValue) + break + } + } + + // Apply regular filters + for (let i = 0; i < dbSpace.filters.length; i++) { + const f = dbSpace.filters[i] + let value = f.value + + if (filterValueMap.has(i)) { + value = filterValueMap.get(i) + } else if (f.op === 'in' && Array.isArray(f.value)) { + // Reconstruct array with encrypted values substituted + value = f.value.map((v, j) => { + const key = `${i}:${j}` + return filterInMap.has(key) ? filterInMap.get(key) : v + }) + } + + const column = f.column + const wasEncrypted = filterValueMap.has(i) + + switch (f.op) { + case 'eq': + q = q.eq(column, value) + break + case 'neq': + q = q.neq(column, value) + break + case 'gt': + q = q.gt(column, value) + break + case 'gte': + q = q.gte(column, value) + break + case 'lt': + q = q.lt(column, value) + break + case 'lte': + q = q.lte(column, value) + break + case 'like': + case 'ilike': + q = applyPatternFilter(q, column, f.op, value) + break + // `matches` (encrypted free-text) and `contains` (plaintext / encrypted + // JSON) share the `cs`/`@>` wire operator; the operand encoding is the + // same, so both emit through the one containment applier. + case 'contains': + case 'matches': + q = applyContainsFilter( + q, + column, + value, + wasEncrypted, + queryDomainsRequired, + ) + break + case 'is': + q = q.is(column, value) + break + case 'in': + // `wasEncrypted` above is false for in-lists: their ciphertexts land + // in `filterInMap`, keyed per element. + q = applyInFilter( + q, + column, + value as unknown[], + Array.isArray(f.value) && + f.value.some((_, j) => filterInMap.has(`${i}:${j}`)), + ) + break + } + } + + // Apply match filters + for (let i = 0; i < dbSpace.matchFilters.length; i++) { + const mf = dbSpace.matchFilters[i] + const resolvedQuery: Record<string, unknown> = {} + + for (const { column: colName, value: originalValue } of mf.entries) { + const key = `${i}:${colName}` + resolvedQuery[colName] = matchValueMap.has(key) + ? matchValueMap.get(key) + : originalValue + } + + q = q.match(resolvedQuery) + } + + // Apply not filters + for (let i = 0; i < dbSpace.notFilters.length; i++) { + const nf = dbSpace.notFilters[i] + + if (nf.op === 'in' && Array.isArray(nf.value)) { + const values = nf.value.map((v, j) => + notInMap.has(`${i}:${j}`) ? notInMap.get(`${i}:${j}`) : v, + ) + q = q.not(nf.column, 'in', formatInListOperand(values)) + continue + } + + const wasEncrypted = notValueMap.has(i) + const value = wasEncrypted ? notValueMap.get(i) : nf.value + + // `contains` is a supabase-js METHOD name, not a PostgREST operator, and + // `q.not()` interpolates its operand with `String(value)` — so an array + // arrives brace-less and an object as `[object Object]`. Build the + // containment literal ourselves and emit the `cs` token, exactly as the + // `.or()` path does. A scalar (including the encrypted envelope, already + // serialized) yields `null` and is forwarded untouched. + if (nf.op === 'contains' || nf.op === 'matches') { + const literal = formatContainmentOperand(value) + q = q.not(nf.column, 'cs', literal ?? value) + continue + } + + // Every `FilterOp` except `contains` spells the same as its PostgREST + // operator, and `contains` was handled above (it also needs its operand + // rewritten), so the recorded op is the wire op. + q = q.not(nf.column, nf.op, value) + } + + // Apply or filters + for (let i = 0; i < dbSpace.orFilters.length; i++) { + const of_ = dbSpace.orFilters[i] + + if (of_.kind === 'string') { + // Already parsed (once) and translated by `toDbSpace`. + const parsed = [...of_.conditions] + + for (let j = 0; j < parsed.length; j++) { + const sub = substituteOrValue(orStringConditionMap, i, j, parsed[j]) + if (sub) { + parsed[j] = { ...parsed[j], value: sub.value } + } + } + + // Rebuild whenever a condition REFERENCES an encrypted column — not + // merely when a value was encrypted. An `is`/null operand on an + // encrypted column encrypts nothing, so keying on "was a value + // substituted" would send that condition down the verbatim path below + // and forward the caller's JS property name to a DB that only knows the + // column's real name. `toDbSpace` has already translated `parsed`. + const referencesEncrypted = parsed.some((c) => + isEncryptedColumn(c.column, encryptedColumnNames), + ) + + if (referencesEncrypted) { + q = q.or(rebuildOrString(parsed), { + referencedTable: of_.referencedTable, + }) + } else { + // Every condition names a plaintext column, whose property name IS + // its DB name — nothing to map. Forward the caller's ORIGINAL string + // byte-for-byte: relied on for nested `and()` and quoted values that + // `parseOrString`/`rebuildOrString` cannot round-trip. + q = q.or(of_.original as DbFilterString, { + referencedTable: of_.referencedTable, + }) + } + } else { + // Structured: convert to string + const conditions = of_.conditions.map((cond, j) => { + const sub = substituteOrValue(orStructuredConditionMap, i, j, cond) + return sub ? { ...cond, value: sub.value } : cond + }) + + q = q.or(rebuildOrString(conditions)) + } + } + + // Apply raw filters + for (let i = 0; i < dbSpace.rawFilters.length; i++) { + const rf = dbSpace.rawFilters[i] + + // An encrypted `in` list was encrypted element-wise; reassemble it into + // the quoted PostgREST list literal, exactly as the `not` path does. A + // plaintext column keeps its operand untouched. + if ( + rf.operator === 'in' && + Array.isArray(rf.value) && + isEncryptedColumn(rf.column, encryptedColumnNames) + ) { + const values = rf.value.map((v, j) => + rawInMap.has(`${i}:${j}`) ? rawInMap.get(`${i}:${j}`) : v, + ) + q = q.filter(rf.column, rf.operator, formatInListOperand(values)) + continue + } + + const value = rawValueMap.has(i) ? rawValueMap.get(i) : rf.value + q = q.filter(rf.column, rf.operator, value) + } + + return q +} diff --git a/packages/stack-supabase/src/query-mutation.ts b/packages/stack-supabase/src/query-mutation.ts new file mode 100644 index 000000000..154f3244a --- /dev/null +++ b/packages/stack-supabase/src/query-mutation.ts @@ -0,0 +1,79 @@ +import { logger } from '@cipherstash/stack/adapter-kit' +import type { ColumnMap } from './column-map' +import { + type EncryptionContext, + EncryptionFailedError, + withOpContext, +} from './query-encrypt' +import type { MutationOp } from './types' + +/** + * Encode an encrypted model for the Supabase request body. The native + * `eql_v3.*` domains are plain jsonb, so the raw encrypted payload is sent + * (keyed by DB column name). + */ +function transformEncryptedMutationModel( + model: Record<string, unknown>, + columns: ColumnMap, +): Record<string, unknown> { + const out: Record<string, unknown> = Object.create(null) + for (const [key, value] of Object.entries(model)) { + out[columns.dbNameFor(key)] = value + } + return out +} + +/** + * Encrypt a mutation's row data — the values being STORED, as opposed to the + * filter operands being searched by (`./query-encrypt`). `delete` carries no + * data, and a builder with no recorded mutation encrypts nothing. + */ +export async function encryptMutationData( + mutation: MutationOp | null, + ctx: EncryptionContext, +): Promise<Record<string, unknown> | Record<string, unknown>[] | null> { + if (!mutation) return null + if (mutation.kind === 'delete') return null + + const data = mutation.data + + if (Array.isArray(data)) { + // Bulk encrypt + const result = await withOpContext( + ctx.encryptionClient.bulkEncryptModels(data, ctx.table), + ctx, + ) + if (result.failure) { + logger.error( + `Supabase: failed to encrypt models for table "${ctx.tableName}"`, + ) + + throw new EncryptionFailedError( + `Failed to encrypt models: ${result.failure.message}`, + result.failure, + ) + } + + return result.data.map((model) => + transformEncryptedMutationModel(model, ctx.columns), + ) + } + + // Single model + const result = await withOpContext( + ctx.encryptionClient.encryptModel(data, ctx.table), + ctx, + ) + if (result.failure) { + logger.error( + `Supabase: failed to encrypt model for table "${ctx.tableName}"`, + ) + + throw new EncryptionFailedError( + `Failed to encrypt model: ${result.failure.message}`, + result.failure, + ) + } + + return transformEncryptedMutationModel(result.data, ctx.columns) +} diff --git a/packages/stack-supabase/src/query-results.ts b/packages/stack-supabase/src/query-results.ts new file mode 100644 index 000000000..128c06fca --- /dev/null +++ b/packages/stack-supabase/src/query-results.ts @@ -0,0 +1,207 @@ +import { DATE_LIKE_CASTS, logger } from '@cipherstash/stack/adapter-kit' +import { selectKeyToDbV3 } from './helpers' +import { + type EncryptionContext, + EncryptionFailedError, + withOpContext, +} from './query-encrypt' +import type { EncryptedSupabaseResponse, ResultMode } from './types' + +/** cast_as kinds that reconstruct to a JS `Date` — shared with the typed v3 + * client's decrypt-model path (see `encryption/v3.ts`). */ +const DATE_LIKE_CAST_SET = new Set<string>(DATE_LIKE_CASTS) + +export type RawSupabaseResult = { + data: unknown + error: { + message: string + details?: string + hint?: string + code?: string + } | null + count?: number | null + status: number + statusText: string +} + +/** What the decrypt step needs beyond the shared encryption context. */ +export type DecryptContext = EncryptionContext & { + selectColumns: string | null + resultMode: ResultMode + hasMutation: boolean +} + +/** + * Post-process a decrypted result row: rebuild `Date` values from the + * encrypt-config `cast_as` (date/timestamp), mirroring the typed v3 client's + * decrypt-model path. + */ +function postprocessDecryptedRow( + row: Record<string, unknown>, + ctx: DecryptContext, +): Record<string, unknown> { + // Every key an encrypted column can appear under: the keys this select + // actually produces (including caller-chosen aliases like `ts:createdAt`), + // plus the static property and DB names as a fallback for paths that record + // no select. Aliases win. Derived here from `ctx.selectColumns` (the row in + // hand) rather than cached from `buildSelectString`, so a reused builder can + // never postprocess a row with a previous operation's stale select map. + const propToDb = ctx.columns.propToDb + const keyToDb: Record<string, string> = Object.assign( + Object.create(null), + ctx.selectColumns === null + ? undefined + : selectKeyToDbV3(ctx.selectColumns, propToDb), + ) + for (const [property, dbName] of Object.entries(propToDb)) { + keyToDb[property] ??= dbName + keyToDb[dbName] ??= dbName + } + + const out: Record<string, unknown> = { ...row } + for (const [key, dbName] of Object.entries(keyToDb)) { + const castAs = ctx.columns.schemaFor(dbName)?.cast_as + if (!DATE_LIKE_CAST_SET.has(castAs as string)) continue + const value = out[key] + if (value == null || value instanceof Date) continue + if (typeof value === 'string' || typeof value === 'number') { + out[key] = new Date(value) + } + } + return out +} + +/** + * Decrypt a PostgREST response into the caller's row type. + * + * Reads EQL v3 only. A column carrying legacy EQL v2 ciphertext is never in + * this adapter's encrypt config — introspection recognises `public.eql_v3_*` + * domains exclusively — so it is returned as an untouched passthrough rather + * than decrypted. To read v2 data, decrypt fetched rows with the core + * `@cipherstash/stack` client, whose decrypt path is generation-agnostic. + */ +export async function decryptResults<T extends object, TData = T[]>( + result: RawSupabaseResult, + ctx: DecryptContext, +): Promise<EncryptedSupabaseResponse<TData>> { + // If there's an error from Supabase, pass it through + if (result.error) { + return { + data: null, + error: { + message: result.error.message, + details: result.error.details, + hint: result.error.hint, + code: result.error.code, + }, + count: result.count ?? null, + status: result.status, + statusText: result.statusText, + } + } + + // No data to decrypt + if (result.data === null || result.data === undefined) { + return { + data: null, + error: null, + count: result.count ?? null, + status: result.status, + statusText: result.statusText, + } + } + + // Determine if we need to decrypt + const hasSelect = ctx.selectColumns !== null + const hasMutationWithReturning = ctx.hasMutation && hasSelect + + if (!hasSelect && !hasMutationWithReturning) { + // No select means no data to decrypt (e.g., insert without .select()) + return { + data: result.data as TData, + error: null, + count: result.count ?? null, + status: result.status, + statusText: result.statusText, + } + } + + // Decrypt based on result mode + if (ctx.resultMode === 'single' || ctx.resultMode === 'maybeSingle') { + if (result.data === null) { + return { + data: null, + error: null, + count: result.count ?? null, + status: result.status, + statusText: result.statusText, + } + } + + // Single result — decrypt one model + const decrypted = await withOpContext( + ctx.encryptionClient.decryptModel(result.data as Record<string, unknown>), + ctx, + ) + if (decrypted.failure) { + logger.error( + `Supabase: failed to decrypt model for table "${ctx.tableName}"`, + ) + + throw new EncryptionFailedError( + `Failed to decrypt model: ${decrypted.failure.message}`, + decrypted.failure, + ) + } + + return { + // ONE row — `TData` is `T` on this path (`single`/`maybeSingle` narrow it), + // so no array wrapping and no cast pretending otherwise. + data: postprocessDecryptedRow( + decrypted.data as Record<string, unknown>, + ctx, + ) as TData, + error: null, + count: result.count ?? null, + status: result.status, + statusText: result.statusText, + } + } + + // Array result — bulk decrypt + const dataArray = result.data as Record<string, unknown>[] + if (dataArray.length === 0) { + return { + data: [] as TData, + error: null, + count: result.count ?? null, + status: result.status, + statusText: result.statusText, + } + } + + const decrypted = await withOpContext( + ctx.encryptionClient.bulkDecryptModels(dataArray), + ctx, + ) + if (decrypted.failure) { + logger.error( + `Supabase: failed to decrypt models for table "${ctx.tableName}"`, + ) + + throw new EncryptionFailedError( + `Failed to decrypt models: ${decrypted.failure.message}`, + decrypted.failure, + ) + } + + return { + data: decrypted.data.map((row) => + postprocessDecryptedRow(row as Record<string, unknown>, ctx), + ) as TData, + error: null, + count: result.count ?? null, + status: result.status, + statusText: result.statusText, + } +} diff --git a/packages/stack-supabase/src/types.ts b/packages/stack-supabase/src/types.ts index 0ea50ca9d..e19f685e9 100644 --- a/packages/stack-supabase/src/types.ts +++ b/packages/stack-supabase/src/types.ts @@ -1,5 +1,4 @@ import type { AuditConfig } from '@cipherstash/stack/adapter-kit' -import type { EncryptionClient } from '@cipherstash/stack/encryption' import type { AnyV3Table, EqlTypeForColumn, @@ -7,11 +6,7 @@ import type { QueryTypesForColumn, } from '@cipherstash/stack/eql/v3' import type { EncryptionError } from '@cipherstash/stack/errors' -import type { LockContext, LockContextInput } from '@cipherstash/stack/identity' -import type { - EncryptedTable, - EncryptedTableColumn, -} from '@cipherstash/stack/schema' +import type { LockContextInput } from '@cipherstash/stack/identity' import type { ClientConfig } from '@cipherstash/stack/types' import type { V3Schemas } from './schema-builder' @@ -19,32 +14,16 @@ import type { V3Schemas } from './schema-builder' // Config & instance // --------------------------------------------------------------------------- -export type EncryptedSupabaseConfig = { - encryptionClient: EncryptionClient - supabaseClient: SupabaseClientLike -} - -export interface EncryptedSupabaseInstance { - from<T extends Record<string, unknown> = Record<string, unknown>>( - tableName: string, - schema: EncryptedTable<EncryptedTableColumn>, - ): EncryptedQueryBuilder<T> -} - -// --------------------------------------------------------------------------- -// EQL v3 config & instance -// --------------------------------------------------------------------------- - export type { V3Schemas } /** - * Options for {@link import('./index').encryptedSupabaseV3}. + * Options for {@link import('./index').encryptedSupabase}. * * @typeParam S - declared v3 tables. When present, `from()` is constrained to * the declared table names and returns typed builders, and the tables are * verified against the database at construction. */ -export type EncryptedSupabaseV3Options< +export type EncryptedSupabaseOptions< S extends V3Schemas | undefined = undefined, > = { /** Postgres connection string for introspection. Defaults to @@ -61,7 +40,7 @@ export type EncryptedSupabaseV3Options< * Declaring a `text_search` column does NOT change its match behaviour: a * declared and a synthesized `text_search` column build byte-identically, and * neither `types.TextSearch` nor `EncryptedTextSearchColumn` accepts match - * options. See the `contains` note on `EncryptedQueryBuilderV3Impl`. + * options. See the `contains` note on `EncryptedQueryBuilderImpl`. */ schemas?: S } @@ -82,7 +61,7 @@ type V3ColumnsOfTable<Table> = Table extends { * the filterable keys so a filter on one is a type error, matching the runtime * guard in the v3 term encryption path. */ -export type NonQueryableV3Keys<Table extends AnyV3Table> = { +export type NonQueryableKeys<Table extends AnyV3Table> = { [K in Extract<keyof V3ColumnsOfTable<Table>, string>]: [ QueryTypesForColumn<V3ColumnsOfTable<Table>[K]>, ] extends [never] @@ -122,9 +101,9 @@ type NonScalarQueryableV3Keys<Table extends AnyV3Table> = { * the table's encrypted columns with no scalar capability (storage-only * columns, and `types.Json` documents — see {@link NonScalarQueryableV3Keys}; * before #650's `searchableJson` arm the two sets coincided). Plaintext - * (non-schema) columns pass through untouched, exactly as in v2. + * (non-schema) columns pass through untouched. */ -export type V3FilterableKeys< +export type FilterableKeys< Table extends AnyV3Table, Row extends Record<string, unknown>, > = Exclude<Extract<keyof Row, string>, NonScalarQueryableV3Keys<Table>> @@ -150,7 +129,7 @@ type NonFreeTextSearchV3Keys<Table extends AnyV3Table> = { * table's encrypted columns that lack a match index. Plaintext columns pass * through, where `contains` is PostgREST's native jsonb/array containment. */ -export type V3FreeTextSearchableKeys< +export type FreeTextSearchableKeys< Table extends AnyV3Table, Row extends Record<string, unknown>, > = Exclude<Extract<keyof Row, string>, NonFreeTextSearchV3Keys<Table>> @@ -159,13 +138,13 @@ export type V3FreeTextSearchableKeys< * Row keys `matches()` accepts: ONLY the table's ENCRYPTED columns that carry a * `freeTextSearch` capability (`public.eql_v3_text_match` / `text_search`). * - * Unlike {@link V3FreeTextSearchableKeys} (which additionally lets plaintext keys + * Unlike {@link FreeTextSearchableKeys} (which additionally lets plaintext keys * through, because the old `contains` also served native containment), this * excludes plaintext columns entirely — `matches()` is encrypted free-text only, * so calling it on a plaintext column is a compile error, not a runtime throw. * Derived from the encrypted-column keys minus the non-free-text ones. */ -export type V3EncryptedFreeTextKeys< +export type EncryptedFreeTextKeys< Table extends AnyV3Table, Row extends Record<string, unknown>, > = Exclude< @@ -194,9 +173,9 @@ type NonSearchableJsonV3Keys<Table extends AnyV3Table> = { * columns whose domain is `public.eql_v3_json_search` (`types.Json`). Plaintext * columns are excluded — on those, `contains()` is PostgREST-native containment * and the selector methods do not apply. Mirror of - * {@link V3EncryptedFreeTextKeys} for the `searchableJson` capability. + * {@link EncryptedFreeTextKeys} for the `searchableJson` capability. */ -export type V3SearchableJsonKeys< +export type SearchableJsonKeys< Table extends AnyV3Table, Row extends Record<string, unknown>, > = Exclude< @@ -265,7 +244,7 @@ type PlaintextContainsValue<V> = V extends readonly unknown[] * excluded here to match the runtime rejection in `validateTransforms`, * rather than type-checking clean and throwing at execute time. */ -export type NonOrderableV3Keys<Table extends AnyV3Table> = { +export type NonOrderableKeys<Table extends AnyV3Table> = { [K in Extract< keyof V3ColumnsOfTable<Table>, string @@ -285,25 +264,25 @@ export type NonOrderableV3Keys<Table extends AnyV3Table> = { * `jsonb_cmp` and compares the random ciphertext first. But the builder does not * emit a bare `ORDER BY`: for an encrypted ordering column it emits the jsonb * path `col->op`, which selects the OPE term, and OPE is order-preserving. See - * `EncryptedQueryBuilderV3Impl.orderColumnName`. + * `EncryptedQueryBuilderImpl.orderColumnName`. * * ORE-backed (`*_ord_ore`) columns are excluded at compile time by - * {@link NonOrderableV3Keys} — the builder sorts through a jsonb path that + * {@link NonOrderableKeys} — the builder sorts through a jsonb path that * cannot reach their superuser-only ORE opclass, so `.order()` on one is a type * error, matching the runtime rejection in `validateTransforms` (defense in * depth for the untyped `.order(someString)` path). */ -export type V3OrderableKeys< +export type OrderableKeys< Table extends AnyV3Table, Row extends Record<string, unknown>, -> = Exclude<Extract<keyof Row, string>, NonOrderableV3Keys<Table>> +> = Exclude<Extract<keyof Row, string>, NonOrderableKeys<Table>> /** * Row keys that are NOT encrypted v3 columns. Used where a method's operand is a * SQL value rather than a ciphertext envelope — `is(col, true)` in particular, * since an encrypted column holds jsonb and can never be `IS TRUE`. */ -export type V3PlaintextKeys< +export type PlaintextKeys< Table extends AnyV3Table, Row extends Record<string, unknown>, > = Exclude< @@ -313,106 +292,106 @@ export type V3PlaintextKeys< /** * The v3 builder type: the shared {@link EncryptedQueryBuilderCore} surface with - * filter methods narrowed to {@link V3FilterableKeys} and `order()` to - * {@link V3OrderableKeys}. + * filter methods narrowed to {@link FilterableKeys} and `order()` to + * {@link OrderableKeys}. * * `like`/`ilike` are absent by construction. EQL v3 free-text search is fuzzy * bloom-filter token matching (`@>`), not SQL wildcard matching — `%` is * tokenized like any other character, so a `like` pattern is a category error. * The v3 dialect of Drizzle omits them for the same reason. Use `matches`. */ -export interface EncryptedQueryBuilderV3< +export interface EncryptedQueryBuilder< Table extends AnyV3Table, Row extends Record<string, unknown>, > extends EncryptedQueryBuilderCore< Row, - V3FilterableKeys<Table, Row> & StringKeyOf<Row>, - EncryptedQueryBuilderV3<Table, Row>, - V3OrderableKeys<Table, Row> & StringKeyOf<Row>, + FilterableKeys<Table, Row> & StringKeyOf<Row>, + EncryptedQueryBuilder<Table, Row>, + OrderableKeys<Table, Row> & StringKeyOf<Row>, // `is(col, true)` is legal only on a PLAINTEXT column: an encrypted column // holds a jsonb envelope, never a SQL boolean. The two axes were threaded // separately "so they can diverge", and now they have — `order()` admits // encrypted ordering columns (sorted by their `op` term), `is(col, true)` // still admits none. - V3PlaintextKeys<Table, Row> & StringKeyOf<Row> + PlaintextKeys<Table, Row> & StringKeyOf<Row> > { /** Encrypted free-text token match on legacy EQL versions. EQL 3.0.2+ * requires a query-domain cast PostgREST cannot express, so this fails fast. */ - matches<K extends V3EncryptedFreeTextKeys<Table, Row> & StringKeyOf<Row>>( + matches<K extends EncryptedFreeTextKeys<Table, Row> & StringKeyOf<Row>>( column: K, value: string, - ): EncryptedQueryBuilderV3<Table, Row> + ): EncryptedQueryBuilder<Table, Row> /** Native (exact) jsonb/array containment (`@>`). Plaintext columns only — an * encrypted column is a compile error (use {@link matches}). A scalar plaintext * column resolves its operand to `never` (`@>` is array/jsonb only). */ - contains<K extends V3PlaintextKeys<Table, Row> & StringKeyOf<Row>>( + contains<K extends PlaintextKeys<Table, Row> & StringKeyOf<Row>>( column: K, value: PlaintextContainsValue<Row[K]>, - ): EncryptedQueryBuilderV3<Table, Row> + ): EncryptedQueryBuilder<Table, Row> /** Encrypted JSON containment on legacy EQL versions. EQL 3.0.2+ requires an * `eql_v3.query_json` cast PostgREST cannot express, so this fails fast before * encrypting an operand into the request URL. */ - contains<K extends V3SearchableJsonKeys<Table, Row> & StringKeyOf<Row>>( + contains<K extends SearchableJsonKeys<Table, Row> & StringKeyOf<Row>>( column: K, value: EncryptedJsonContainsValue, - ): EncryptedQueryBuilderV3<Table, Row> + ): EncryptedQueryBuilder<Table, Row> /** Encrypted JSONPath equality on legacy EQL versions. EQL 3.0.2+ fails fast * because PostgREST cannot express the required query-domain cast. */ - selectorEq<K extends V3SearchableJsonKeys<Table, Row> & StringKeyOf<Row>>( + selectorEq<K extends SearchableJsonKeys<Table, Row> & StringKeyOf<Row>>( column: K, path: string, value: SelectorLeafValue, - ): EncryptedQueryBuilderV3<Table, Row> + ): EncryptedQueryBuilder<Table, Row> /** Encrypted JSONPath inequality on legacy EQL versions. EQL 3.0.2+ fails * fast because PostgREST cannot express the required query-domain cast. */ - selectorNe<K extends V3SearchableJsonKeys<Table, Row> & StringKeyOf<Row>>( + selectorNe<K extends SearchableJsonKeys<Table, Row> & StringKeyOf<Row>>( column: K, path: string, value: SelectorLeafValue, - ): EncryptedQueryBuilderV3<Table, Row> + ): EncryptedQueryBuilder<Table, Row> /** Raw legacy containment spelling. EQL 3.0.2+ rejects this before sending. */ - filter<K extends V3SearchableJsonKeys<Table, Row> & StringKeyOf<Row>>( + filter<K extends SearchableJsonKeys<Table, Row> & StringKeyOf<Row>>( column: K, operator: 'cs', value: EncryptedJsonContainsValue, - ): EncryptedQueryBuilderV3<Table, Row> - filter<K extends V3FilterableKeys<Table, Row> & StringKeyOf<Row>>( + ): EncryptedQueryBuilder<Table, Row> + filter<K extends FilterableKeys<Table, Row> & StringKeyOf<Row>>( column: K, operator: string, value: Row[K], - ): EncryptedQueryBuilderV3<Table, Row> + ): EncryptedQueryBuilder<Table, Row> /** Negated legacy containment spelling. EQL 3.0.2+ rejects this before * sending. */ - not<K extends V3SearchableJsonKeys<Table, Row> & StringKeyOf<Row>>( + not<K extends SearchableJsonKeys<Table, Row> & StringKeyOf<Row>>( column: K, operator: 'contains', value: EncryptedJsonContainsValue, - ): EncryptedQueryBuilderV3<Table, Row> - not<K extends V3FilterableKeys<Table, Row> & StringKeyOf<Row>>( + ): EncryptedQueryBuilder<Table, Row> + not<K extends FilterableKeys<Table, Row> & StringKeyOf<Row>>( column: K, operator: string, value: Row[K], - ): EncryptedQueryBuilderV3<Table, Row> + ): EncryptedQueryBuilder<Table, Row> } /** - * The v3 builder for a table with no declared schema. Without capability + * The builder for a table with no declared schema. Without capability * information `contains` cannot be narrowed to match-indexed columns — the - * runtime guard in the term-encryption path is the only protection — but the - * DIALECT is still v3, so `like`/`ilike` are absent here too. Typing this as - * {@link EncryptedQueryBuilder} would hand back the v2 surface. + * runtime guard in the term-encryption path is the only protection — and + * `order()`/`is(col, true)` cannot be narrowed either, so this surface takes + * {@link EncryptedQueryBuilderCore}'s `OK`/`BK` defaults. `like`/`ilike` are + * absent here as on the typed surface. * * For the same reason nothing here can tell an encrypted match column from a * plaintext jsonb one, so `matches`/`contains` accept the full native operand * union (which subsumes the encrypted column's `string`); the runtime resolves * the column and picks the encoding (and rejects the wrong-column-kind pairing). */ -export interface EncryptedQueryBuilderV3Untyped< - Row extends Record<string, unknown>, -> extends EncryptedQueryBuilderCore< +export interface EncryptedQueryBuilderUntyped<Row extends object> + extends EncryptedQueryBuilderCore< Row, StringKeyOf<Row>, - EncryptedQueryBuilderV3Untyped<Row> + EncryptedQueryBuilderUntyped<Row> > { /** Fuzzy free-text token match on an encrypted match/search column. The * operand is always the string term to tokenize (never an array/object), even @@ -420,33 +399,33 @@ export interface EncryptedQueryBuilderV3Untyped< matches<K extends StringKeyOf<Row>>( column: K, value: string, - ): EncryptedQueryBuilderV3Untyped<Row> + ): EncryptedQueryBuilderUntyped<Row> /** Native jsonb/array containment on plaintext columns. Encrypted JSON * containment fails fast on EQL 3.0.2+. */ contains<K extends StringKeyOf<Row>>( column: K, value: NativeContainsValue, - ): EncryptedQueryBuilderV3Untyped<Row> + ): EncryptedQueryBuilderUntyped<Row> /** Legacy encrypted JSONPath equality; fails fast on EQL 3.0.2+. */ selectorEq<K extends StringKeyOf<Row>>( column: K, path: string, value: SelectorLeafValue, - ): EncryptedQueryBuilderV3Untyped<Row> + ): EncryptedQueryBuilderUntyped<Row> /** Legacy encrypted JSONPath inequality; fails fast on EQL 3.0.2+. */ selectorNe<K extends StringKeyOf<Row>>( column: K, path: string, value: SelectorLeafValue, - ): EncryptedQueryBuilderV3Untyped<Row> + ): EncryptedQueryBuilderUntyped<Row> } /** Untyped instance (no `schemas`): rows default to `Record<string, unknown>` * and `from` accepts any table name. */ -export interface EncryptedSupabaseV3Instance { - from<Row extends Record<string, unknown> = Record<string, unknown>>( +export interface EncryptedSupabaseInstance { + from<Row extends object = Record<string, unknown>>( tableName: string, - ): EncryptedQueryBuilderV3Untyped<Row> + ): EncryptedQueryBuilderUntyped<Row> } /** Typed instance (with `schemas: S`): a declared table name resolves to the @@ -463,19 +442,58 @@ export interface EncryptedSupabaseV3Instance { * Overload order matters: the literal-constrained signature is declared first, * so TypeScript prefers it whenever the argument is a declared key and only * falls through to `string` otherwise. */ -export interface TypedEncryptedSupabaseV3Instance<S extends V3Schemas> { +export interface TypedEncryptedSupabaseInstance<S extends V3Schemas> { from<K extends keyof S & string>( table: K, - ): EncryptedQueryBuilderV3<S[K], InferPlaintext<S[K]>> - from<Row extends Record<string, unknown> = Record<string, unknown>>( + ): EncryptedQueryBuilder<S[K], InferPlaintext<S[K]>> + from<Row extends object = Record<string, unknown>>( table: string, - ): EncryptedQueryBuilderV3Untyped<Row> + ): EncryptedQueryBuilderUntyped<Row> } // --------------------------------------------------------------------------- // Response // --------------------------------------------------------------------------- +/** + * The builder returned by `single()`/`maybeSingle()`: awaits to a SINGLE row + * (`data: T | null`) instead of an array. + * + * FILTERS and TRANSFORMS are deliberately absent — applying one after `single()` + * would change the query the single-row promise was made about. + * + * What IS carried is exactly: `then` (via `PromiseLike`), `abortSignal`, + * `throwOnError`, `returns`, `withLockContext` and `audit`. That is a + * hand-written list, not a passthrough. It narrows the static API only: + * `single()`/`maybeSingle()` return the same builder instance, so a method + * omitted here is still a property of that object at runtime — the type is what + * stops you reaching it, not the implementation. + * + * It is therefore NOT parity with postgrest-js, which carries a different set: + * its `single()` returns a `PostgrestBuilder`, carrying + * `returns`/`overrideTypes`/`throwOnError`/`setHeader` (and NOT `abortSignal`, + * which lives on `PostgrestTransformBuilder`). Relative to that, this adapter + * keeps `abortSignal` as a deliberate superset — an abort is not a query change + * — and adds the two encryption-specific configurators, which the runtime reads + * at execute time and so remain valid after `single()`; but postgrest-js's + * `overrideTypes` and `setHeader` have NO adapter equivalent, on this surface or + * any other. + */ +export interface EncryptedSingleQueryBuilder<T> + extends PromiseLike<EncryptedSupabaseResponse<T>> { + abortSignal(signal: AbortSignal): EncryptedSingleQueryBuilder<T> + throwOnError(): EncryptedSingleQueryBuilder<T> + /** Re-type the ROW. The single-row awaited shape is preserved — `U`, not `U[]`. + * `object`, not `Record<string, unknown>`: an `interface` row type has no + * implicit index signature and would be rejected by the latter (upstream + * postgrest-js constrains its `returns` type parameter not at all). */ + returns<U extends object>(): EncryptedSingleQueryBuilder<U> + /** Bind identity-aware encryption. Read at execute time, so order-independent. */ + withLockContext(lockContext: LockContextInput): EncryptedSingleQueryBuilder<T> + /** Attach audit metadata. Read at execute time, so order-independent. */ + audit(config: AuditConfig): EncryptedSingleQueryBuilder<T> +} + export type EncryptedSupabaseResponse<T> = { data: T | null error: EncryptedSupabaseError | null @@ -630,7 +648,7 @@ declare const DbBrand: unique symbol */ export type DbName = string & { readonly [DbBrand]: 'column' } -/** A PostgREST select list, DB-space and `::jsonb`-cast. Minted by `addJsonbCasts`/`addJsonbCastsV3`. */ +/** A PostgREST select list, DB-space and `::jsonb`-cast. Minted by `addJsonbCastsV3`. */ export type DbSelect = string & { readonly [DbBrand]: 'select' } /** A PostgREST `or()` filter string in DB-space. Minted by `rebuildOrString`. */ @@ -647,7 +665,7 @@ export type DbMutationOptions = Record<string, unknown> & { // --------------------------------------------------------------------------- // DB-space IR — the recorded query, with every column name translated. // -// `toDbSpace()` (see ./query-builder) maps the property-space IR above into +// `toDbSpace()` (see ./query-dbspace) maps the property-space IR above into // this one, exactly once, before any column name can reach PostgREST. The // branded `column` fields make that translation a compile-time obligation: // `applyFilters`/`buildAndExecuteQuery` consume only these types, so feeding @@ -697,6 +715,19 @@ export type DbMutationOp = | Extract<MutationOp, { kind: 'update' }> | Extract<MutationOp, { kind: 'delete' }> +/** The whole recorded query, in PROPERTY space — the builder's chained state as + * handed to `toDbSpace()`. The mirror of {@link DbQuerySpace} on the untranslated + * side of that boundary. */ +export type RecordedOps = { + filters: PendingFilter[] + matchFilters: PendingMatchFilter[] + notFilters: PendingNotFilter[] + rawFilters: PendingRawFilter[] + orFilters: PendingOrFilter[] + transforms: TransformOp[] + mutation: MutationOp | null +} + /** The whole recorded query, in DB-space. */ export type DbQuerySpace = { filters: DbPendingFilter[] @@ -787,33 +818,43 @@ export type { type StringKeyOf<T> = Extract<keyof T, string> /** - * Every builder method shared by the v2 and v3 dialects. `Self` is the concrete - * builder each method returns, so a dialect that omits a method (v3 omits - * `like`/`ilike`) does not have it laundered back in by a chained call whose - * return type widened to the base interface. + * Every builder method shared by the TYPED ({@link EncryptedQueryBuilder}) and + * UNTYPED ({@link EncryptedQueryBuilderUntyped}) surfaces. Both are EQL v3 — + * they differ only in how much they can narrow, not in dialect. * - * Free-text search is the ONLY axis on which the two dialects differ: v2 - * matches with SQL wildcards (`like`/`ilike` → `~~`), v3 with token containment - * (`contains` → `@>`). Each adds its own method below. + * `Self` is the concrete builder each method returns, so a surface that omits a + * method does not have it laundered back in by a chained call whose return type + * widened to the base interface. + * + * Free-text search lives on the sub-interfaces rather than here, because its + * key set differs between the two: `matches()` narrows to the encrypted + * match/search columns on the typed surface, and to every row key on the + * untyped one. Neither surface carries `like`/`ilike` — EQL v3 free-text is + * fuzzy bloom-token matching, not SQL pattern matching, so the builder rewrites + * a `like` on an encrypted column to `matches` at record time (see + * `query-builder.ts`). They survive in this file only as the internal + * {@link FilterOp} union and on the raw {@link SupabaseQueryBuilder} seam, both + * of which still serve plaintext columns. */ export interface EncryptedQueryBuilderCore< - T extends Record<string, unknown>, + T extends object, FK extends StringKeyOf<T>, Self, - /** Keys `order()` accepts. Defaults to `FK`, so the v2 surface is unchanged; - * v3 narrows it to plaintext columns (see {@link V3OrderableKeys}). */ + /** Keys `order()` accepts. The typed surface narrows it to the orderable + * columns (see {@link OrderableKeys}); it defaults to `FK` for the untyped + * surface, which has no capability information to narrow with. */ OK extends StringKeyOf<T> = FK, - /** Keys the BOOLEAN form of `is()` accepts. Defaults to `FK`, so the v2 - * surface is unchanged; v3 narrows it to plaintext columns. Distinct from - * `OK` on purpose: "sortable" and "IS TRUE-able" are different capability - * axes that happen to select the same keys today, and narrowing `order()` - * later must not silently narrow `is()` with it. */ + /** Keys the BOOLEAN form of `is()` accepts. The typed surface narrows it to + * plaintext columns; it defaults to `FK` for the untyped surface, as `OK` + * does. Distinct from `OK` on purpose: "sortable" and "IS TRUE-able" are + * different capability axes that happen to select the same keys today, and + * narrowing `order()` later must not silently narrow `is()` with it. */ BK extends StringKeyOf<T> = FK, > extends PromiseLike<EncryptedSupabaseResponse<T[]>> { /** `columns` defaults to `'*'`, matching supabase-js. A `'*'` select expands - * to the introspected column list when one is available (v3), and otherwise - * throws — v2 has no column list to cast, so `select()` and `select('*')` - * both throw there. */ + * to the introspected column list; when none is available (a client that + * could not introspect) both `select()` and `select('*')` throw, because an + * unexpanded `*` cannot cast the encrypted columns with `::jsonb`. */ select( columns?: string, options?: { head?: boolean; count?: 'exact' | 'planned' | 'estimated' }, @@ -879,9 +920,10 @@ export interface EncryptedQueryBuilderCore< options?: { referencedTable?: string; foreignTable?: string }, ): Self match(query: Partial<T>): Self - // `OK`, not `FK`: v3 cannot order by ANY encrypted column, because PostgREST - // cannot emit `ORDER BY eql_v3.ord_term(col)` and a bare `ORDER BY` sorts the - // ciphertext envelope. `OK` defaults to `FK`, so the v2 surface is unchanged. + // `OK`, not `FK`: an encrypted column is orderable only when its domain + // carries an OPE term (PostgREST reaches it as `col->op`); a bare `ORDER BY` + // would sort the ciphertext envelope. `OK` defaults to `FK` on the untyped + // surface, where the runtime `validateTransforms` guard is the only check. order<K extends OK>( column: K, options?: { @@ -900,24 +942,104 @@ export interface EncryptedQueryBuilderCore< to: number, options?: { referencedTable?: string; foreignTable?: string }, ): Self - single(): Self - maybeSingle(): Self + /** + * Return ONE row rather than an array — so the awaited `data` is `T | null`, + * not `T[]`. Returns {@link EncryptedSingleQueryBuilder} rather than `Self` + * because that change of shape is the whole point of the call: typing it + * `Self` would keep promising `T[]` while the runtime hands back one object, + * forcing every caller through a cast (`data as unknown as Row`). + * + * Filters and transforms are not chainable afterwards, matching supabase-js — + * `single()` is applied last. + */ + single(): EncryptedSingleQueryBuilder<T> + /** As {@link single}, but a zero-row result is `data: null` rather than an + * error. Same `T | null` awaited shape — `single()` reports the missing row + * through `error` instead. */ + maybeSingle(): EncryptedSingleQueryBuilder<T> csv(): Self abortSignal(signal: AbortSignal): Self throwOnError(): Self - /** Escape hatch: re-types the rows and drops back to the v2 builder surface. */ - returns<U extends Record<string, unknown>>(): EncryptedQueryBuilder<U> + /** Escape hatch: re-types the rows and drops back to the untyped v3 builder + * surface. `object`, not `Record<string, unknown>`: an `interface` row type + * has no implicit index signature and would be rejected by the latter, while + * `object` still excludes `string`/`number` row types. */ + returns<U extends object>(): EncryptedQueryBuilderUntyped<U> /** Bind identity-aware encryption. Accepts either a plain * `{ identityClaim }` (the common form) or a `LockContext` instance. */ withLockContext(lockContext: LockContextInput): Self audit(config: AuditConfig): Self } -/** The v2 builder: free-text search via SQL wildcard matching. */ -export interface EncryptedQueryBuilder< - T extends Record<string, unknown> = Record<string, unknown>, - FK extends StringKeyOf<T> = StringKeyOf<T>, -> extends EncryptedQueryBuilderCore<T, FK, EncryptedQueryBuilder<T, FK>> { - like<K extends FK>(column: K, pattern: string): EncryptedQueryBuilder<T, FK> - ilike<K extends FK>(column: K, pattern: string): EncryptedQueryBuilder<T, FK> -} +// --------------------------------------------------------------------------- +// Deprecated `*V3` aliases (Decision 5 — supabase keeps type-identical aliases). +// The v3 names are now the unsuffixed canonical exports; these aliases keep +// existing `*V3` imports compiling. +// --------------------------------------------------------------------------- + +/** @deprecated Use {@link EncryptedSupabaseOptions}. */ +export type EncryptedSupabaseV3Options< + S extends V3Schemas | undefined = undefined, +> = EncryptedSupabaseOptions<S> + +/** @deprecated Use {@link NonQueryableKeys}. */ +export type NonQueryableV3Keys<Table extends AnyV3Table> = + NonQueryableKeys<Table> + +/** @deprecated Use {@link FilterableKeys}. */ +export type V3FilterableKeys< + Table extends AnyV3Table, + Row extends Record<string, unknown>, +> = FilterableKeys<Table, Row> + +/** @deprecated Use {@link FreeTextSearchableKeys}. */ +export type V3FreeTextSearchableKeys< + Table extends AnyV3Table, + Row extends Record<string, unknown>, +> = FreeTextSearchableKeys<Table, Row> + +/** @deprecated Use {@link EncryptedFreeTextKeys}. */ +export type V3EncryptedFreeTextKeys< + Table extends AnyV3Table, + Row extends Record<string, unknown>, +> = EncryptedFreeTextKeys<Table, Row> + +/** @deprecated Use {@link SearchableJsonKeys}. */ +export type V3SearchableJsonKeys< + Table extends AnyV3Table, + Row extends Record<string, unknown>, +> = SearchableJsonKeys<Table, Row> + +/** @deprecated Use {@link NonOrderableKeys}. */ +export type NonOrderableV3Keys<Table extends AnyV3Table> = + NonOrderableKeys<Table> + +/** @deprecated Use {@link OrderableKeys}. */ +export type V3OrderableKeys< + Table extends AnyV3Table, + Row extends Record<string, unknown>, +> = OrderableKeys<Table, Row> + +/** @deprecated Use {@link PlaintextKeys}. */ +export type V3PlaintextKeys< + Table extends AnyV3Table, + Row extends Record<string, unknown>, +> = PlaintextKeys<Table, Row> + +/** @deprecated Use {@link EncryptedQueryBuilder}. */ +export type EncryptedQueryBuilderV3< + Table extends AnyV3Table, + Row extends Record<string, unknown>, +> = EncryptedQueryBuilder<Table, Row> + +/** @deprecated Use {@link EncryptedQueryBuilderUntyped}. */ +export type EncryptedQueryBuilderV3Untyped< + Row extends Record<string, unknown>, +> = EncryptedQueryBuilderUntyped<Row> + +/** @deprecated Use {@link EncryptedSupabaseInstance}. */ +export type EncryptedSupabaseV3Instance = EncryptedSupabaseInstance + +/** @deprecated Use {@link TypedEncryptedSupabaseInstance}. */ +export type TypedEncryptedSupabaseV3Instance<S extends V3Schemas> = + TypedEncryptedSupabaseInstance<S> diff --git a/packages/stack/README.md b/packages/stack/README.md index f5450a725..9e5710eda 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -57,7 +57,7 @@ The wizard will authenticate you, walk you through choosing a database connectio Define a table with concrete EQL v3 column types, build the typed client, and encrypt: ```typescript -import { EncryptionV3 } from "@cipherstash/stack/v3" +import { Encryption } from "@cipherstash/stack/v3" import { encryptedTable, types } from "@cipherstash/stack/eql/v3" // Define a schema — the column type fixes its query capabilities @@ -66,7 +66,7 @@ const users = encryptedTable("users", { }) // Create a typed client -const client = await EncryptionV3({ schemas: [users] }) +const client = await Encryption({ schemas: [users] }) // Encrypt a value const encrypted = await client.encrypt("hello@example.com", { @@ -362,7 +362,7 @@ The one limitation is the ORE-backed `*OrdOre` domains: their ordering term need ### Drizzle Integration -The `@cipherstash/stack-drizzle/v3` subpath (of the separate `@cipherstash/stack-drizzle` package) provides Drizzle-native column factories, schema extraction, and auto-encrypting, capability-checked query operators. +The separate `@cipherstash/stack-drizzle` package provides Drizzle-native column factories, schema extraction, and auto-encrypting, capability-checked query operators. It is EQL v3 only, all on the package root — the EQL v2 surface was removed and the old `./v3` subpath collapsed into `.`. Declare a Drizzle table using the `types` factories — each factory emits its domain as the column's SQL type, so `drizzle-kit generate` produces `ADD COLUMN email public.eql_v3_text_search` etc.: @@ -371,10 +371,10 @@ import { pgTable, integer } from "drizzle-orm/pg-core" import { drizzle } from "drizzle-orm/postgres-js" import { types, - createEncryptionOperatorsV3, - extractEncryptionSchemaV3, -} from "@cipherstash/stack-drizzle/v3" -import { EncryptionV3 } from "@cipherstash/stack/v3" + createEncryptionOperators, + extractEncryptionSchema, +} from "@cipherstash/stack-drizzle" +import { Encryption } from "@cipherstash/stack/v3" // Capabilities come from the concrete type — no flags to configure. const users = pgTable("users", { @@ -389,9 +389,9 @@ const users = pgTable("users", { Derive the v3 schema from the table, build the typed client, and create the operators: ```ts -const usersSchema = extractEncryptionSchemaV3(users) -const client = await EncryptionV3({ schemas: [usersSchema] }) -const ops = createEncryptionOperatorsV3(client) +const usersSchema = extractEncryptionSchema(users) +const client = await Encryption({ schemas: [usersSchema] }) +const ops = createEncryptionOperators(client) const db = drizzle({ client: sqlClient }) ``` @@ -443,12 +443,12 @@ Notes: ### Supabase Integration -`encryptedSupabaseV3` from the separate `@cipherstash/stack-supabase` package wraps a Supabase client and **introspects the database at connect time** — it detects EQL v3 columns by their Postgres domain and builds the encryption client internally: +`encryptedSupabase` from the separate `@cipherstash/stack-supabase` package wraps a Supabase client and **introspects the database at connect time** — it detects EQL v3 columns by their Postgres domain and builds the encryption client internally: ```typescript -import { encryptedSupabaseV3 } from "@cipherstash/stack-supabase" +import { encryptedSupabase } from "@cipherstash/stack-supabase" -const es = await encryptedSupabaseV3(supabaseUrl, supabaseKey) +const es = await encryptedSupabase(supabaseUrl, supabaseKey) await es.from("users").insert({ email: "a@b.com", age: 30 }) await es.from("users").select("id, email").eq("email", "a@b.com") @@ -478,7 +478,7 @@ explicit strategies cover the other cases: ```typescript import { OidcFederationStrategy } from "@cipherstash/stack" -import { EncryptionV3 } from "@cipherstash/stack/v3" +import { Encryption } from "@cipherstash/stack/v3" // The callback is re-invoked on every (re-)federation and must return the // CURRENT third-party OIDC JWT. @@ -488,7 +488,7 @@ const strategy = OidcFederationStrategy.create( ) if (strategy.failure) throw new Error(strategy.failure.error.message) -const client = await EncryptionV3({ +const client = await Encryption({ schemas: [users], config: { authStrategy: strategy.data }, }) @@ -528,8 +528,10 @@ same claim must be supplied to encrypt and decrypt. Lock contexts work with all operations: `encrypt`, `decrypt`, `encryptModel`, `decryptModel`, `bulkEncryptModels`, `bulkDecryptModels`, `bulkEncrypt`, `bulkDecrypt`, `encryptQuery`. `.withLockContext()` also accepts a `LockContext` instance. -On the typed client, `decryptModel` / `bulkDecryptModels` take the lock -context as an optional third argument instead of chaining. +On the typed client, `decryptModel` / `bulkDecryptModels` additionally accept +the lock context as an optional third argument. Use that or `.withLockContext()`, +not both — chaining onto a decrypt that already took a positional lock context +throws. > **Deprecated: `LockContext.identify()`.** Per-operation CTS tokens were removed > in `protect-ffi` 0.25; the token `identify()` fetches is no longer used by @@ -598,10 +600,10 @@ See the [Going to Production](https://cipherstash.com/docs/stack/deploy/going-to Pass config directly when initializing the client: ```typescript -import { EncryptionV3 } from "@cipherstash/stack/v3" +import { Encryption } from "@cipherstash/stack/v3" import { users } from "./schema" -const client = await EncryptionV3({ +const client = await Encryption({ schemas: [users], config: { workspaceCrn: "crn:ap-southeast-2.aws:your-workspace-id", @@ -618,7 +620,7 @@ const client = await EncryptionV3({ Isolate encryption keys per tenant using keysets: ```typescript -const client = await EncryptionV3({ +const client = await Encryption({ schemas: [users], config: { keyset: { id: "123e4567-e89b-12d3-a456-426614174000" }, @@ -626,7 +628,7 @@ const client = await EncryptionV3({ }) // or by name -const client2 = await EncryptionV3({ +const client2 = await Encryption({ schemas: [users], config: { keyset: { name: "Company A" }, @@ -683,10 +685,10 @@ if (result.failure) { ## API Reference -### `EncryptionV3(config)` - Initialize the typed client +### `Encryption(config)` - Initialize the typed client ```typescript -function EncryptionV3(config: { +function Encryption(config: { schemas: AnyV3Table[] config?: ClientConfig }): Promise<TypedEncryptionClient> @@ -707,14 +709,14 @@ Method signatures are derived from your schemas: plaintext arguments are pinned `returnType` controls the encrypted query term's shape: `'eql'` (default, the EQL JSON payload for the ORM adapters), `'composite-literal'` (a Postgres composite string for `.eq()`/string-based APIs), or `'escaped-composite-literal'` (the same, escaped for embedding). Most users take the default; the adapters set it as needed. | `encryptQuery` | `(terms: ScalarQueryTerm[])` | `BatchEncryptQueryOperation` (thenable) | | `encryptModel` | `(model, table)` | `EncryptModelOperation` (thenable) | -| `decryptModel` | `(encryptedModel, table, lockContext?)` | `Promise<Result<...>>` | +| `decryptModel` | `(encryptedModel, table, lockContext?)` | `AuditableDecryptModelOperation` (thenable) | | `bulkEncryptModels` | `(models, table)` | `BulkEncryptModelsOperation` (thenable) | -| `bulkDecryptModels` | `(encryptedModels, table, lockContext?)` | `Promise<Result<...>>` | +| `bulkDecryptModels` | `(encryptedModels, table, lockContext?)` | `AuditableDecryptModelOperation` (thenable) | | `bulkEncrypt` | `(plaintexts, { column, table })` | `BulkEncryptOperation` (thenable) | | `bulkDecrypt` | `(encryptedPayloads)` | `BulkDecryptOperation` (thenable) | | `getEncryptConfig` | `()` | The resolved encrypt config | -The thenable operations support `.withLockContext(lockContext)` for identity-aware encryption. `decryptModel` / `bulkDecryptModels` return a plain `Promise` instead — pass the lock context as the optional third argument. `decrypt` of a single value cannot be strongly typed (a lone ciphertext carries no column identity), and `encryptQuery` rejects storage-only columns at compile time. +The thenable operations support `.withLockContext(lockContext)` for identity-aware encryption, and `decryptModel` / `bulkDecryptModels` also support `.audit({ metadata })`. Those two additionally accept the lock context as an optional third argument — use one form or the other. `decrypt` of a single value cannot be strongly typed (a lone ciphertext carries no column identity), and `encryptQuery` rejects storage-only columns at compile time. ### `LockContext` (legacy) @@ -749,9 +751,9 @@ type UserEncrypted = InferEncrypted<typeof users> | Import Path | Provides | |-------|-----| -| `@cipherstash/stack/v3` | `EncryptionV3` typed client factory, `typedClient`, plus re-exports of the EQL v3 authoring DSL | +| `@cipherstash/stack/v3` | `Encryption` typed client factory (`EncryptionV3` is a `@deprecated` alias), `typedClient`, plus re-exports of the EQL v3 authoring DSL | | `@cipherstash/stack/eql/v3` | EQL v3 authoring DSL: `encryptedTable`, the `types` namespace, `buildEncryptConfig`, inference types (`InferPlaintext`, `InferEncrypted`, ...) | -| `@cipherstash/stack` | `Encryption` function (legacy v2 entry point), auth strategies | +| `@cipherstash/stack` | `Encryption` — the single client factory (overloaded: an array of concrete EQL v3 tables yields the typed v3 client) — plus auth strategies | | `@cipherstash/stack/schema` | Legacy v2 schema builders (see [Legacy: EQL v2](#legacy-eql-v2)) | | `@cipherstash/stack/identity` | `LockContext` class and identity types | | `@cipherstash/stack/client` | Client-safe exports (schema builders and types only - no native FFI) | @@ -762,46 +764,53 @@ depend on `@cipherstash/stack` (they are no longer subpaths of it): | Package | Provides | |-------|-----| -| `@cipherstash/stack-drizzle/v3` | EQL v3 Drizzle integration: `types` column factories, `createEncryptionOperatorsV3`, `extractEncryptionSchemaV3`, `makeEqlV3Column`, `EncryptionOperatorError` | -| `@cipherstash/stack-supabase` | Supabase integration: `encryptedSupabaseV3` (and the legacy v2 `encryptedSupabase`) | -| `@cipherstash/stack-drizzle` | Legacy EQL v2 Drizzle integration (root subpath): `encryptedType`, `extractEncryptionSchema`, `createEncryptionOperators` | +| `@cipherstash/stack-drizzle` | EQL v3 Drizzle integration (package root, v3 only): `types` column factories, `createEncryptionOperators`, `extractEncryptionSchema`, `makeEqlV3Column`, `EncryptionOperatorError` | +| `@cipherstash/stack-supabase` | Supabase integration (v3 only): `encryptedSupabase` (`encryptedSupabaseV3` is a `@deprecated` alias) | ## Legacy: EQL v2 Before the concrete-domain types above, encrypted columns were declared with chainable capability builders and stored in a single `eql_v2_encrypted` -composite column type. The scalar surface remains supported for existing -deployments, but new work should use EQL v3. Legacy searchable JSON cannot be -emitted by protect-ffi 0.30 and must migrate to v3 `types.Json`: +composite column type. **v2 is now a read path, not an authoring surface**: +`decrypt` / `decryptModel` still read stored v2 payloads, so existing +deployments keep working, but new work must use EQL v3. Legacy searchable JSON +cannot be emitted by protect-ffi 0.30 and must migrate to v3 `types.Json`: - **Client and schema**: `Encryption` from `@cipherstash/stack` with `encryptedColumn("email").equality().freeTextSearch().orderAndRange()` and - the builders from `@cipherstash/stack/schema`. v2 and v3 tables cannot be - mixed in one client. + the builders from `@cipherstash/stack/schema`. These are still exported but + `@deprecated` — do not author new schemas with them. v2 and v3 tables cannot + be mixed in one client. - **Query formatting**: v2 query terms can be rendered as strings with `returnType: 'composite-literal'` / `'escaped-composite-literal'` for string-based APIs. -- **Integrations**: the v2 Drizzle surface is the root of - `@cipherstash/stack-drizzle` (`encryptedType`, `extractEncryptionSchema`, - `createEncryptionOperators`); the v2 Supabase surface is `encryptedSupabase`. -- **DynamoDB still requires v2**: `encryptedDynamoDB` from - `@cipherstash/stack/dynamodb` works with the v2 API only — v3 support is - tracked in [#657](https://github.com/cipherstash/stack/issues/657). +- **Integrations are v3 only.** `encryptedSupabase` is the EQL v3 factory — + there is no v2 Supabase wrapper any more. Likewise + `@cipherstash/stack-drizzle` dropped `encryptedType` and the v2 operators. + Existing v2 columns reached through either adapter are read-only: decrypt + them through `@cipherstash/stack` and migrate to a v3 domain. +- **DynamoDB writes EQL v3 only.** `encryptedDynamoDB` from + `@cipherstash/stack/dynamodb` encrypts with `types.*` v3 tables; its decrypt + methods still accept a v2 table so previously stored items remain readable. Full v2 documentation lives at [cipherstash.com/docs](https://cipherstash.com/docs). ### Migrating from @cipherstash/protect -`@cipherstash/protect` users land on the legacy v2 surface first — the mapping -below is 1:1, and method signatures on the encryption client (`encrypt`, -`decrypt`, `encryptModel`, etc.) and the `Result` pattern (`data` / `failure`) -are unchanged. From there, adopt EQL v3 for new tables: +Method signatures on the encryption client (`encrypt`, `decrypt`, +`encryptModel`, ...) and the `Result` pattern (`data` / `failure`) are unchanged. +**Declare tables with the EQL v3 DSL** — the v2 builders below are `@deprecated` +and exist to read and migrate data already written as v2, not to author new +columns. A column's capabilities come from its `types.*` domain rather than +chained tuners: `csColumn("email").equality().freeTextSearch()` becomes +`types.TextSearch("email")`. -| `@cipherstash/protect` | `@cipherstash/stack` (legacy v2) | Import Path | +| `@cipherstash/protect` | `@cipherstash/stack` | Import Path | |------------|-----------|-------| | `protect(config)` | `Encryption(config)` | `@cipherstash/stack` | -| `csTable(name, cols)` | `encryptedTable(name, cols)` | `@cipherstash/stack/schema` | -| `csColumn(name)` | `encryptedColumn(name)` | `@cipherstash/stack/schema` | +| `csTable(name, cols)` | `encryptedTable(name, cols)` | `@cipherstash/stack/eql/v3` | +| `csColumn(name)` | `types.<Domain>(name)` (e.g. `types.TextSearch`) | `@cipherstash/stack/eql/v3` | +| `csTable`/`csColumn` for READING legacy v2 data | `encryptedTable` / `encryptedColumn` (`@deprecated`) | `@cipherstash/stack/schema` | | `import { LockContext } from "@cipherstash/protect/identify"` | `import { LockContext } from "@cipherstash/stack/identity"` | `@cipherstash/stack/identity` | | N/A | CLI | `npx stash` | diff --git a/packages/stack/__tests__/audit.test.ts b/packages/stack/__tests__/audit.test.ts index bb14447bd..edf2b1df2 100644 --- a/packages/stack/__tests__/audit.test.ts +++ b/packages/stack/__tests__/audit.test.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { LockContext } from '@/identity' import { Encryption } from '@/index' import { encryptedColumn, encryptedTable } from '@/schema' @@ -20,7 +21,7 @@ type User = { number?: number } -let protectClient: Awaited<ReturnType<typeof Encryption>> +let protectClient: EncryptionClient beforeAll(async () => { protectClient = await Encryption({ diff --git a/packages/stack/__tests__/backward-compat.test.ts b/packages/stack/__tests__/backward-compat.test.ts index 915108cc7..cdf5c930c 100644 --- a/packages/stack/__tests__/backward-compat.test.ts +++ b/packages/stack/__tests__/backward-compat.test.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { Encryption } from '@/index' import { encryptedColumn, encryptedTable } from '@/schema' @@ -8,7 +9,7 @@ const users = encryptedTable('users', { }) describe('k-field discriminator (EQL v2.3)', () => { - let protectClient: Awaited<ReturnType<typeof Encryption>> + let protectClient: EncryptionClient beforeAll(async () => { protectClient = await Encryption({ schemas: [users] }) diff --git a/packages/stack/__tests__/basic-protect.test.ts b/packages/stack/__tests__/basic-protect.test.ts index a889328b0..af687818c 100644 --- a/packages/stack/__tests__/basic-protect.test.ts +++ b/packages/stack/__tests__/basic-protect.test.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { Encryption } from '@/index' import { encryptedColumn, encryptedTable } from '@/schema' @@ -9,7 +10,7 @@ const users = encryptedTable('users', { json: encryptedColumn('json').dataType('json'), }) -let protectClient: Awaited<ReturnType<typeof Encryption>> +let protectClient: EncryptionClient beforeAll(async () => { protectClient = await Encryption({ diff --git a/packages/stack/__tests__/bulk-protect.test.ts b/packages/stack/__tests__/bulk-protect.test.ts index 45342f91b..10d615c7c 100644 --- a/packages/stack/__tests__/bulk-protect.test.ts +++ b/packages/stack/__tests__/bulk-protect.test.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { LockContext } from '@/identity' import { Encryption } from '@/index' import { encryptedColumn, encryptedTable } from '@/schema' @@ -19,7 +20,7 @@ type User = { number?: number } -let protectClient: Awaited<ReturnType<typeof Encryption>> +let protectClient: EncryptionClient beforeAll(async () => { protectClient = await Encryption({ diff --git a/packages/stack/__tests__/decrypt-audit-forwarding.test.ts b/packages/stack/__tests__/decrypt-audit-forwarding.test.ts new file mode 100644 index 000000000..f69519e81 --- /dev/null +++ b/packages/stack/__tests__/decrypt-audit-forwarding.test.ts @@ -0,0 +1,175 @@ +/** + * Runtime proof that audit metadata attached to the typed EQL v3 client's + * `decryptModel` / `bulkDecryptModels` reaches ZeroKMS — the core half of + * acceptance #2b. Before PR 3 the typed client `await`ed the underlying decrypt + * and mapped the value, which collapsed the chain and dropped `.audit()`; the + * `MappedDecryptOperation` wrapper restores it. + * + * The metadata surfaces as `unverifiedContext` on the mocked protect-ffi + * `decryptBulk` call. Both chaining orders are covered (Risk R3): + * `.audit().withLockContext()` and `.withLockContext().audit()` must each + * forward the metadata (and the lock context's identity claim). + * + * Credential-free: protect-ffi is mocked, so there is no ZeroKMS round-trip. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Encryption } from '@/index' + +// A protect-ffi-shaped encrypted payload so the SDK's `isEncryptedPayload` +// check detects the model field as encrypted and routes it to `decryptBulk`. +const enc = () => ({ v: 2, i: { t: 'users', c: 'email' }, c: 'ciphertext' }) + +vi.mock('@cipherstash/protect-ffi', () => ({ + newClient: vi.fn(async () => ({ __mock: 'client' })), + encrypt: vi.fn(async () => enc()), + decrypt: vi.fn(async () => 'decrypted'), + encryptBulk: vi.fn(async (_c: unknown, opts: { plaintexts: unknown[] }) => + opts.plaintexts.map(enc), + ), + decryptBulk: vi.fn(async (_c: unknown, opts: { ciphertexts: unknown[] }) => + opts.ciphertexts.map(() => 'decrypted'), + ), + decryptBulkFallible: vi.fn( + async (_c: unknown, opts: { ciphertexts: unknown[] }) => + opts.ciphertexts.map(() => ({ data: 'decrypted' })), + ), + encryptQuery: vi.fn(async () => enc()), + encryptQueryBulk: vi.fn(async (_c: unknown, opts: { queries: unknown[] }) => + opts.queries.map(enc), + ), +})) + +import * as ffi from '@cipherstash/protect-ffi' +import type { EncryptionClientFor } from '@/encryption/v3' +// Imported after the mock so the v3 table builder is available; `Encryption` +// returns the typed client for an all-v3 schema set. +import { encryptedTable, types } from '@/encryption/v3' + +const users = encryptedTable('users', { + email: types.TextEq('email'), +}) + +const IDENTITY_CLAIM = { identityClaim: ['sub'] } + +// biome-ignore lint/suspicious/noExplicitAny: test helper unwraps Result +function unwrap(result: any) { + if (result.failure) { + throw new Error(`operation failed: ${result.failure.message}`) + } + return result.data +} + +/** Options the FFI decrypt was last called with (second arg). */ +// biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args +const lastDecryptOpts = () => (ffi.decryptBulk as any).mock.calls.at(-1)[1] + +/** The lock context is carried per-ciphertext, not on the top-level opts. */ +const lastCiphertextLockContext = () => + lastDecryptOpts().ciphertexts[0]?.lockContext + +let client: EncryptionClientFor<readonly [typeof users]> +let prevWorkspaceCrn: string | undefined + +beforeEach(async () => { + vi.clearAllMocks() + prevWorkspaceCrn = process.env.CS_WORKSPACE_CRN + process.env.CS_WORKSPACE_CRN = 'crn:ap-southeast-2.aws:test-workspace' + client = await Encryption({ schemas: [users] }) +}) + +afterEach(() => { + // Restore the prior value so this suite doesn't leak env state into other + // Vitest suites sharing the worker. + if (prevWorkspaceCrn === undefined) { + delete process.env.CS_WORKSPACE_CRN + } else { + process.env.CS_WORKSPACE_CRN = prevWorkspaceCrn + } +}) + +describe('typed v3 client: audit metadata forwards through decryptModel', () => { + it('forwards .audit({ metadata }) as unverifiedContext (no lock context)', async () => { + unwrap( + await client + .decryptModel({ email: enc() }, users) + .audit({ metadata: { sub: 'u1' } }), + ) + + expect(ffi.decryptBulk).toHaveBeenCalledTimes(1) + expect(lastDecryptOpts().unverifiedContext).toEqual({ sub: 'u1' }) + }) + + it('forwards metadata AND identity claim with .audit().withLockContext()', async () => { + unwrap( + await client + .decryptModel({ email: enc() }, users) + .audit({ metadata: { m: 1 } }) + .withLockContext(IDENTITY_CLAIM), + ) + + expect(ffi.decryptBulk).toHaveBeenCalledTimes(1) + const opts = lastDecryptOpts() + expect(opts.unverifiedContext).toEqual({ m: 1 }) + expect(lastCiphertextLockContext()).toEqual(IDENTITY_CLAIM) + }) + + it('forwards metadata AND identity claim with .withLockContext().audit()', async () => { + unwrap( + await client + .decryptModel({ email: enc() }, users) + .withLockContext(IDENTITY_CLAIM) + .audit({ metadata: { m: 2 } }), + ) + + expect(ffi.decryptBulk).toHaveBeenCalledTimes(1) + const opts = lastDecryptOpts() + expect(opts.unverifiedContext).toEqual({ m: 2 }) + expect(lastCiphertextLockContext()).toEqual(IDENTITY_CLAIM) + }) + + it('forwards metadata via the lockContext argument (no chaining)', async () => { + unwrap( + await client + .decryptModel({ email: enc() }, users, IDENTITY_CLAIM) + .audit({ metadata: { m: 3 } }), + ) + + expect(ffi.decryptBulk).toHaveBeenCalledTimes(1) + const opts = lastDecryptOpts() + expect(opts.unverifiedContext).toEqual({ m: 3 }) + expect(lastCiphertextLockContext()).toEqual(IDENTITY_CLAIM) + }) + + it('throws when a second lock context is chained onto an already-bound op', () => { + // The wrapper always exposes `withLockContext`, so a positional bind + // followed by a chained one type-checks. Silently keeping the first would + // drop the caller's intent (and fail later at ZeroKMS with an opaque + // rejection); reject the re-bind at the call site instead. + const op = client.decryptModel({ email: enc() }, users, IDENTITY_CLAIM) + + expect(() => op.withLockContext({ identityClaim: ['other'] })).toThrow( + /already bound to a lock context/i, + ) + }) + + it('throws on a re-bind for bulkDecryptModels too', () => { + const op = client.bulkDecryptModels([{ email: enc() }], users, { + identityClaim: ['sub'], + }) + + expect(() => op.withLockContext(IDENTITY_CLAIM)).toThrow( + /already bound to a lock context/i, + ) + }) + + it('forwards .audit({ metadata }) on bulkDecryptModels', async () => { + unwrap( + await client + .bulkDecryptModels([{ email: enc() }], users) + .audit({ metadata: { b: 4 } }), + ) + + expect(ffi.decryptBulk).toHaveBeenCalledTimes(1) + expect(lastDecryptOpts().unverifiedContext).toEqual({ b: 4 }) + }) +}) diff --git a/packages/stack/__tests__/dynamodb/client-compat.test-d.ts b/packages/stack/__tests__/dynamodb/client-compat.test-d.ts index 2c322357a..715b9b665 100644 --- a/packages/stack/__tests__/dynamodb/client-compat.test-d.ts +++ b/packages/stack/__tests__/dynamodb/client-compat.test-d.ts @@ -14,8 +14,9 @@ import { describe, expectTypeOf, it } from 'vitest' import type { EncryptedAttributes, EncryptedDynamoDBInstance } from '@/dynamodb' import { encryptedDynamoDB } from '@/dynamodb' +import type { CallableEncryptionClient } from '@/dynamodb/types' import type { EncryptionClient } from '@/encryption' -import type { EncryptionV3 } from '@/encryption/v3' +import type { EncryptionClientFor } from '@/encryption/v3' import { encryptedTable as encryptedTableV3, types } from '@/eql/v3' import { encryptedColumn, encryptedTable as encryptedTableV2 } from '@/schema' @@ -42,12 +43,17 @@ const searchV3 = encryptedTableV3('search_v3', { type V3Model = { pk: string; email?: string; age?: number; role?: string } -// The two client shapes. `EncryptionV3` returns a `TypedEncryptionClient` -// parameterised by its own schema tuple; `Encryption` returns the nominal -// `EncryptionClient`. Both must be accepted by the factory WITHOUT a cast. -declare const typedClient: Awaited< - ReturnType<typeof EncryptionV3<readonly [typeof usersV3]>> -> +// The two client shapes. A v3 schema tuple yields a `TypedEncryptionClient` +// parameterised by it; a v2 set yields the nominal `EncryptionClient`. Both must +// be accepted by the factory WITHOUT a cast. +// +// Named with `EncryptionClientFor`, not `Awaited<ReturnType<typeof Encryption>>` +// — `ReturnType` reads the LAST overload, so that idiom resolves to the nominal +// client whatever schemas you pass. Supplying an explicit type argument happens +// to dodge it today, but only because it filters the non-generic nominal +// overload out of the instantiation; give that overload a type parameter and +// this assertion would silently start checking the wrong client. +declare const typedClient: EncryptionClientFor<readonly [typeof usersV3]> declare const nominalClient: EncryptionClient describe('encryptedDynamoDB accepts both client shapes without a cast', () => { @@ -79,27 +85,25 @@ describe('encryptedDynamoDB accepts both client shapes without a cast', () => { const dynamo = encryptedDynamoDB({ encryptionClient: typedClient }) -describe('all four methods accept both a v3 and a v2 table', () => { - it('encryptModel', () => { +describe('write is EQL v3 only; read accepts both a v3 and a v2 table', () => { + it('encryptModel accepts a v3 table and rejects a v2 table', () => { expectTypeOf(dynamo.encryptModel).toBeCallableWith( { pk: 'a', email: 'a@b.com' }, usersV3, ) - expectTypeOf(dynamo.encryptModel).toBeCallableWith( - { pk: 'a', email: 'a@b.com' }, - usersV2, - ) + // Write is EQL v3 only — the v2 write overload was removed, so a v2 table is + // rejected on encrypt (decrypt below still accepts it). + // @ts-expect-error - encryptModel no longer accepts an EQL v2 table + dynamo.encryptModel({ pk: 'a', email: 'a@b.com' }, usersV2) }) - it('bulkEncryptModels', () => { + it('bulkEncryptModels accepts a v3 table and rejects a v2 table', () => { expectTypeOf(dynamo.bulkEncryptModels).toBeCallableWith( [{ pk: 'a', email: 'a@b.com' }], usersV3, ) - expectTypeOf(dynamo.bulkEncryptModels).toBeCallableWith( - [{ pk: 'a', email: 'a@b.com' }], - usersV2, - ) + // @ts-expect-error - bulkEncryptModels no longer accepts an EQL v2 table + dynamo.bulkEncryptModels([{ pk: 'a', email: 'a@b.com' }], usersV2) }) it('decryptModel', () => { @@ -332,18 +336,6 @@ describe('a required-nullable v3 column keeps __source required', () => { }) }) -describe('the v2 overload still returns the input model', () => { - it('keeps an existing v2 caller compiling unchanged', async () => { - const result = await dynamo.encryptModel<{ pk: string; email?: string }>( - { pk: 'a', email: 'a@b.com' }, - usersV2, - ) - if (result.failure) throw new Error(result.failure.message) - - expectTypeOf(result.data).toEqualTypeOf<{ pk: string; email?: string }>() - }) -}) - describe('operations chain and resolve', () => { it('.audit() returns the operation so it stays chainable', () => { const op = dynamo.encryptModel({ pk: 'a', email: 'a@b.com' }, usersV3) @@ -352,6 +344,32 @@ describe('operations chain and resolve', () => { >() }) + it('.audit() is chainable on decryptModel and returns the operation', () => { + // The DynamoDB decrypt op is chainable; audit metadata now forwards to the + // underlying client decrypt regardless of client shape (see + // resolve-decrypt.test.ts / decrypt-audit-forwarding.test.ts for the runtime + // proof). Here we lock the type-level surface. + const op = dynamo.decryptModel({ pk: 'a', email__source: 'ct' }, usersV3) + expectTypeOf(op).toHaveProperty('audit') + expectTypeOf(op.audit({ metadata: { sub: 'u1' } })).toEqualTypeOf< + typeof op + >() + }) + + it('awaiting decryptModel yields a discriminated Result', async () => { + const result = await dynamo.decryptModel( + { pk: 'a', email__source: 'ct' }, + usersV3, + ) + + if (result.failure) { + expectTypeOf(result.failure.message).toEqualTypeOf<string>() + return + } + + expectTypeOf(result.data.email).toEqualTypeOf<string>() + }) + it('awaiting yields a discriminated Result', async () => { const result = await dynamo.encryptModel( { pk: 'a', email: 'a@b.com' }, @@ -371,3 +389,37 @@ describe('operations chain and resolve', () => { expectTypeOf(result.data.email__source).toEqualTypeOf<string>() }) }) + +/** + * The INTERNAL client view, `CallableEncryptionClient` — the shape the operation + * classes cast to. Distinct from everything above, which is the PUBLIC instance. + * + * All four members must return `unknown`. The shipped clients disagree about + * what an operation is: the nominal client returns a chainable + * `EncryptModelOperation`, the typed client a `MappedDecryptOperation`, and the + * wasm-inline client a bare `Promise<WasmResult>` carrying no `.audit()` at all. + * Declaring a chainable shape here asserts an `.audit()` that entry does not + * have — and that assertion is not academic: it is what let the write path chain + * `.audit()` unconditionally and fail EVERY EQL v3 write on the wasm entry + * (#788 review follow-up). + * + * `unknown` forces every call site through `resolveEncryptResult` / + * `resolveDecryptResult`, which normalise all three shapes and fail closed. The + * encrypt members were the last two still declaring the lie. + */ +describe('the internal callable client view is shape-agnostic', () => { + it('returns unknown from all four members, so no shape is presumed', () => { + expectTypeOf< + ReturnType<CallableEncryptionClient['encryptModel']> + >().toBeUnknown() + expectTypeOf< + ReturnType<CallableEncryptionClient['bulkEncryptModels']> + >().toBeUnknown() + expectTypeOf< + ReturnType<CallableEncryptionClient['decryptModel']> + >().toBeUnknown() + expectTypeOf< + ReturnType<CallableEncryptionClient['bulkDecryptModels']> + >().toBeUnknown() + }) +}) diff --git a/packages/stack/__tests__/dynamodb/construct-guard.test.ts b/packages/stack/__tests__/dynamodb/construct-guard.test.ts index 88c95e997..9ab09ea72 100644 --- a/packages/stack/__tests__/dynamodb/construct-guard.test.ts +++ b/packages/stack/__tests__/dynamodb/construct-guard.test.ts @@ -68,7 +68,9 @@ describe('encryptedDynamoDB client/table version guard', () => { message = (e as Error).message } expect(message).toContain('users_v3') - expect(message).toMatch(/EncryptionV3|eqlVersion/) + // Post-collapse the fix guidance points at `Encryption` (which auto-selects + // the v3 wire format for a v3 schema set), not `EncryptionV3`/`eqlVersion`. + expect(message).toMatch(/build it with Encryption\(/) }) it('guards every operation method, not just encryptModel', () => { diff --git a/packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts b/packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts index c32b41f10..1e2b5fd94 100644 --- a/packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts +++ b/packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts @@ -21,9 +21,8 @@ import { beforeAll, describe, expect, it } from 'vitest' import { encryptedDynamoDB } from '@/dynamodb' import { toItemWithEqlPayloads } from '@/dynamodb/helpers' import type { EncryptedDynamoDBInstance } from '@/dynamodb/types' -import type { EncryptionClient } from '@/encryption' -import { EncryptionV3 } from '@/encryption/v3' -import type { JsonValue } from '@/eql/v3' +import { type EncryptionClientFor, EncryptionV3 } from '@/encryption/v3' +import type { AnyV3Table, JsonValue } from '@/eql/v3' import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' @@ -66,7 +65,7 @@ type User = { /** The typed client from `EncryptionV3` — the documented v3 entry point. */ let typedDynamo: EncryptedDynamoDBInstance /** The nominal chainable client, forced into v3 mode. */ -let nominalClient: EncryptionClient +let nominalClient: EncryptionClientFor<readonly AnyV3Table[]> let nominalDynamo: EncryptedDynamoDBInstance beforeAll(async () => { @@ -362,7 +361,7 @@ describe('nested attributes with a v3 table', liveSuiteOptions, () => { } let nestedDynamo: EncryptedDynamoDBInstance - let nestedClient: EncryptionClient + let nestedClient: EncryptionClientFor<readonly AnyV3Table[]> beforeAll(async () => { nestedClient = await Encryption({ @@ -432,8 +431,8 @@ describe('nested attributes with a v3 table', liveSuiteOptions, () => { if (stored.failure) throw new Error(stored.failure.message) const term = await nestedClient.encryptQuery(ssn, { - table: nested as never, - column: nested['profile.ssn'] as never, + table: nested, + column: nested['profile.ssn'], }) if (term.failure) throw new Error(term.failure.message) @@ -477,7 +476,7 @@ describe( }) let renamedDynamo: EncryptedDynamoDBInstance - let renamedClient: EncryptionClient + let renamedClient: EncryptionClientFor<readonly AnyV3Table[]> beforeAll(async () => { renamedClient = await Encryption({ @@ -517,8 +516,8 @@ describe( expect(decrypted.data).toEqual(original) const term = await renamedClient.encryptQuery('c@d.com', { - table: renamed as never, - column: renamed.emailAddress as never, + table: renamed, + column: renamed.emailAddress, }) if (term.failure) throw new Error(term.failure.message) @@ -580,8 +579,8 @@ describe( if (stored.failure) throw new Error(stored.failure.message) const term = await nominalClient.encryptQuery(email, { - table: users as never, - column: users.email as never, + table: users, + column: users.email, }) if (term.failure) throw new Error(term.failure.message) @@ -624,7 +623,7 @@ describe('audit metadata with a v3 table', liveSuiteOptions, () => { expect(decrypted.data).toEqual(item) }) - it('is accepted on the typed client, though decrypt cannot carry it', async () => { + it('is carried on every operation of the typed client too', async () => { const item: User = { pk: 'user#15', email: 'ken@example.com' } const encrypted = await typedDynamo @@ -632,9 +631,11 @@ describe('audit metadata with a v3 table', liveSuiteOptions, () => { .audit({ metadata }) if (encrypted.failure) throw new Error(encrypted.failure.message) - // The typed client's `decryptModel` returns a plain promise with no audit - // surface. The chain must still resolve correctly — the metadata is simply - // not forwarded. Use the nominal client if decrypt audit matters. + // The typed client's `decryptModel` now returns a `MappedDecryptOperation`, + // so the metadata forwards to ZeroKMS here as it does on the nominal client. + // That forwarding is proven credential-free in + // `__tests__/decrypt-audit-forwarding.test.ts`; this live test asserts the + // round-trip still decrypts with the chain attached. const decrypted = await typedDynamo .decryptModel(encrypted.data, users) .audit({ metadata }) diff --git a/packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts b/packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts index f742afb9d..77f31bf4e 100644 --- a/packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts +++ b/packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts @@ -1,12 +1,31 @@ /** - * Pure unit tests for the two client-shape helpers that bridge the nominal - * `EncryptionClient` (chainable, carries `.audit()`) and the typed client from - * `EncryptionV3` (plain `Promise<Result>`). Both branches were previously only - * reachable through a live ZeroKMS decrypt; these move that assurance onto the - * pure CI lane. No credentials, no network. + * Pure unit tests for the client-shape helpers behind the DynamoDB adapter's + * decrypt AND encrypt paths. + * + * On decrypt, both native clients — nominal `EncryptionClient` and the typed EQL + * v3 client (whose decrypt returns a `MappedDecryptOperation`) — are chainable + * and carry `.audit()`; the bare-promise branch is taken by the wasm-inline + * client and by a non-conforming custom one. + * + * The same split exists on encrypt, and only the decrypt half handled it: the + * encrypt operations chained `.audit()` unconditionally, so the wasm-inline + * client failed every write (#788 review follow-up). `resolveEncryptResult` is + * the mirror, and the chainable half of its coverage matters most — the native + * clients' encrypt audit trail has no other credential-free test. + * + * Every branch was previously reachable only through live ZeroKMS; these move + * that assurance onto the pure CI lane. No credentials, no network. */ +import type { Result } from '@byteslice/result' import { afterEach, describe, expect, it, vi } from 'vitest' -import { resolveDecryptResult, throwPreservingCode } from '@/dynamodb/helpers' +import { + resolveDecryptResult, + resolveEncryptResult, + throwPreservingCode, +} from '@/dynamodb/helpers' +import { EncryptionOperation } from '@/encryption/operations/base-operation' +import { MappedDecryptOperation } from '@/encryption/operations/mapped-decrypt' +import { type EncryptionError, EncryptionErrorTypes } from '@/errors' import { logger } from '@/utils/logger' // The metadata-drop tests `vi.spyOn(logger, 'debug')` — the same shared singleton @@ -18,7 +37,7 @@ afterEach(() => { }) describe('resolveDecryptResult', () => { - it('awaits a plain promise when the operation has no .audit (typed client)', async () => { + it('awaits a plain promise when the operation has no .audit (custom client)', async () => { const result = await resolveDecryptResult( Promise.resolve({ data: { x: 1 } }), { metadata: { ignored: true } }, @@ -56,11 +75,13 @@ describe('resolveDecryptResult', () => { // A non-conforming client that resolves to a bare value (or `{}`) has // neither `data` nor `failure`. Casting it straight through would surface a // fake success carrying `undefined`; the shape must be rejected instead. - for (const malformed of [{}, 42, undefined]) { + // `null` and `[]` pin the two clauses that are otherwise untested: without + // the `resolved === null` guard, `'data' in null` throws a TypeError, and an + // array passes `typeof === 'object'` so it must be rejected on the key checks. + for (const malformed of [{}, 42, undefined, null, []]) { const result = await resolveDecryptResult(Promise.resolve(malformed), {}) - expect(result.failure).toBeDefined() - expect(typeof result.failure?.message).toBe('string') + expect(result.failure?.message).toMatch(/malformed result/) expect(result.data).toBeUndefined() } }) @@ -81,6 +102,27 @@ describe('resolveDecryptResult', () => { } }) + it('does not blame the typed client in the dropped-metadata message', async () => { + // Both shipped clients now carry `.audit()` on decrypt, so this branch is + // reachable only from a non-conforming custom client. The message used to + // tell the reader to switch to `Encryption({ config: { eqlVersion: 3 } })` + // for audited decrypts, which is no longer true of any shipped client. + const spy = vi.spyOn(logger, 'debug').mockImplementation(() => {}) + + try { + await resolveDecryptResult(Promise.resolve({ data: { x: 1 } }), { + metadata: { m: 42 }, + }) + + const message = spy.mock.calls.at(-1)?.[0] as string + expect(message).not.toMatch(/eqlVersion/) + expect(message).not.toMatch(/EncryptionV3/) + expect(message).not.toMatch(/typed client/) + } finally { + spy.mockRestore() + } + }) + it('does not log about audit metadata when none is passed', async () => { const spy = vi.spyOn(logger, 'debug').mockImplementation(() => {}) @@ -92,6 +134,198 @@ describe('resolveDecryptResult', () => { spy.mockRestore() } }) + + it('forwards audit metadata through a MappedDecryptOperation and applies its map', async () => { + // The typed EQL v3 client returns a `MappedDecryptOperation` on decrypt. + // resolveDecryptResult sees its `.audit()` and chains it; the wrapper + // forwards the metadata to the underlying op (whose `execute` reads it) and + // maps the successful result. This is the DynamoDB half of acceptance #2b. + let seenMetadata: Record<string, unknown> | undefined + class Underlying extends EncryptionOperation<{ v: number }> { + override async execute(): Promise< + Result<{ v: number }, EncryptionError> + > { + seenMetadata = this.getAuditData().metadata + return { data: { v: 1 } } + } + } + + const mapped = new MappedDecryptOperation< + { v: number }, + { mapped: number } + >(new Underlying(), (value) => ({ mapped: value.v + 1 }), { + failure: { + type: EncryptionErrorTypes.DecryptionError, + message: 'unknown table', + }, + }) + + const result = await resolveDecryptResult(mapped, { metadata: { m: 7 } }) + + expect(result).toEqual({ data: { mapped: 2 } }) + expect(seenMetadata).toEqual({ m: 7 }) + }) + + it('returns the precomputed failure from a MappedDecryptOperation with no map (unknown table)', async () => { + class Underlying extends EncryptionOperation<{ v: number }> { + override async execute(): Promise< + Result<{ v: number }, EncryptionError> + > { + return { data: { v: 1 } } + } + } + + const unknownTableFailure = { + failure: { + type: EncryptionErrorTypes.DecryptionError, + message: 'unknown table', + }, + } + const mapped = new MappedDecryptOperation< + { v: number }, + { mapped: number } + >(new Underlying(), undefined, unknownTableFailure) + + const result = await resolveDecryptResult(mapped, { metadata: { m: 7 } }) + + expect(result.failure?.message).toBe('unknown table') + expect(result.data).toBeUndefined() + }) +}) + +/** + * The write-path mirror (#788 review follow-up). The encrypt operations used + * to chain `.audit()` unconditionally, so a client returning a bare promise + * (the wasm-inline entry) failed every encrypt with + * `.audit is not a function`. These pin BOTH directions: the chainable path + * must still forward metadata — the native clients' audit trail depends on it, + * and its only other coverage is a live-credential suite — and the bare path + * must resolve instead of throwing. + */ +describe('resolveEncryptResult', () => { + /** + * The NATIVE shape, not a hand-rolled lookalike. + * + * `EncryptionOperation.audit()` returns `this` and the operation is a thenable + * whose `then()` calls `execute()` — so the metadata is read back out of the + * operation at execution time, not passed forward as an argument. A stub whose + * `audit()` returns a promise satisfies the helper while testing none of that: + * break `audit()` so it stops recording, or break `then()` so it never + * executes, and such a stub still passes while every native encrypt loses its + * audit trail. Subclass the real base class instead, exactly as the decrypt + * half of this file does with `MappedDecryptOperation`. + */ + it('forwards metadata into a real native operation and executes it once', async () => { + let seenMetadata: Record<string, unknown> | undefined + let executions = 0 + + class NativeEncrypt extends EncryptionOperation<{ encrypted: boolean }> { + override async execute(): Promise< + Result<{ encrypted: boolean }, EncryptionError> + > { + executions += 1 + seenMetadata = this.getAuditData().metadata + return { data: { encrypted: true } } + } + } + + const result = await resolveEncryptResult( + new NativeEncrypt(), + { metadata: { sub: 'u1' } }, + 'encryptModel', + ) + + expect(result).toEqual({ data: { encrypted: true } }) + // The metadata reached `execute()` — the only place it can reach ZeroKMS. + expect(seenMetadata).toEqual({ sub: 'u1' }) + // Awaiting a thenable that `.audit()` returned as `this` must not run it twice. + expect(executions).toBe(1) + }) + + it('propagates a native operation failure without unwrapping it', async () => { + class FailingEncrypt extends EncryptionOperation<{ encrypted: boolean }> { + override async execute(): Promise< + Result<{ encrypted: boolean }, EncryptionError> + > { + return { + failure: { + type: EncryptionErrorTypes.EncryptionError, + message: 'ffi exploded', + }, + } + } + } + + const result = await resolveEncryptResult( + new FailingEncrypt(), + { metadata: { sub: 'u1' } }, + 'encryptModel', + ) + + expect(result.failure?.message).toBe('ffi exploded') + expect(result.data).toBeUndefined() + }) + + it('awaits a bare promise instead of throwing (wasm-inline encrypt)', async () => { + const result = await resolveEncryptResult( + Promise.resolve({ data: { encrypted: true } }), + { metadata: { dropped: true } }, + 'encryptModel', + ) + + expect(result).toEqual({ data: { encrypted: true } }) + }) + + it('propagates a failure result unchanged', async () => { + const failure = { failure: { message: 'boom', code: 'X' } } + + await expect( + resolveEncryptResult(Promise.resolve(failure), {}, 'bulkEncryptModels'), + ).resolves.toEqual(failure) + }) + + it('returns a failure — not a fake success — for a malformed result', async () => { + // Fail closed on every non-Result shape. `null` and `[]` are here + // deliberately: `null` is what makes the `resolved === null` clause + // load-bearing (without it, `'data' in null` throws a TypeError rather than + // returning the intended message), and `typeof [] === 'object'` means an + // array reaches the property checks and must still be rejected. + for (const malformed of [{}, 42, undefined, null, []]) { + const result = await resolveEncryptResult( + Promise.resolve(malformed), + {}, + 'encryptModel', + ) + + expect(result.failure?.message).toMatch(/malformed result/) + expect(result.data).toBeUndefined() + } + }) + + it('names the operation in the dropped-metadata log, and stays silent without metadata', async () => { + const spy = vi.spyOn(logger, 'debug').mockImplementation(() => {}) + + try { + await resolveEncryptResult( + Promise.resolve({ data: {} }), + { metadata: { m: 1 } }, + 'bulkEncryptModels', + ) + expect(spy).toHaveBeenCalledWith( + expect.stringContaining('bulkEncryptModels audit metadata ignored'), + ) + + spy.mockClear() + await resolveEncryptResult( + Promise.resolve({ data: {} }), + {}, + 'encryptModel', + ) + expect(spy).not.toHaveBeenCalled() + } finally { + spy.mockRestore() + } + }) }) describe('throwPreservingCode', () => { diff --git a/packages/stack/__tests__/dynamodb/v2-table-forwarding.test.ts b/packages/stack/__tests__/dynamodb/v2-table-forwarding.test.ts new file mode 100644 index 000000000..d13ad10b7 --- /dev/null +++ b/packages/stack/__tests__/dynamodb/v2-table-forwarding.test.ts @@ -0,0 +1,350 @@ +/** + * Which table (if any) the DynamoDB adapter forwards to the encryption client. + * + * `encryptedDynamoDB` promises that `decryptModel` / `bulkDecryptModels` keep + * accepting an EQL **v2** table so previously stored v2 items stay readable + * (see the contract note in `src/dynamodb/index.ts`). That promise breaks if the + * adapter forwards the v2 table to a v3-configured client: the typed client + * looks the table up in its own reconstructor map, does not find it, and fails + * with "decryptModel received a table this client was not initialized with". + * + * The nominal client derives the table from the payloads and needs no second + * argument, and the typed client now exposes a table-less overload for exactly + * this case — so the correct forward is conditional on the table's generation, + * not unconditional. + * + * Credential-free by construction: the adapter never touches the AWS SDK, and + * these drive it with a recording stub client, so the assertion is on the call + * the adapter makes rather than on a live decrypt. That matters because the only + * other coverage of this path is an integration suite requiring live ZeroKMS. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { encryptedDynamoDB } from '@/dynamodb' +import { encryptedTable as encryptedTableV3, types } from '@/eql/v3' +import { encryptedColumn, encryptedTable as encryptedTableV2 } from '@/schema' +import { logger } from '@/utils/logger' + +// The audit-drop tests spy on the shared `logger` singleton, which other suites +// in this directory also patch. Each restores its own spy in a `finally`; this +// is the safety net so a patched method can never survive a test boundary. +afterEach(() => { + vi.restoreAllMocks() +}) + +const usersV2 = encryptedTableV2('users_v2', { + email: encryptedColumn('email').equality(), +}) + +const usersV3 = encryptedTableV3('users_v3', { + email: types.TextEq('email'), +}) + +/** + * A client that records how each decrypt method was called. `getEncryptConfig` + * reports `knownTables` so the construction-time version guard sees a client + * that knows the v3 table under test. + */ +function recordingClient(knownTables: string[]) { + const calls: { method: string; argCount: number; table: unknown }[] = [] + + const record = + (method: string) => + (...args: unknown[]) => { + calls.push({ method, argCount: args.length, table: args[1] }) + return Promise.resolve({ data: {} }) + } + + const client = { + getEncryptConfig: () => ({ + v: 1, + tables: Object.fromEntries(knownTables.map((t) => [t, {}])), + }), + encryptModel: record('encryptModel'), + bulkEncryptModels: record('bulkEncryptModels'), + decryptModel: record('decryptModel'), + bulkDecryptModels: record('bulkDecryptModels'), + } + + return { calls, client } +} + +describe('decryptModel table forwarding', () => { + it('does not forward an EQL v2 table to the client', async () => { + const { calls, client } = recordingClient([]) + const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) + + await dynamo.decryptModel({ pk: 'a' }, usersV2) + + expect(calls).toHaveLength(1) + expect(calls[0]?.method).toBe('decryptModel') + // The v2 table must not reach the client — a typed client would reject it. + expect(calls[0]?.table).toBeUndefined() + }) + + it('still forwards an EQL v3 table, which the typed client requires', async () => { + const { calls, client } = recordingClient(['users_v3']) + const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) + + await dynamo.decryptModel({ pk: 'a' }, usersV3) + + expect(calls).toHaveLength(1) + expect(calls[0]?.table).toBe(usersV3) + }) +}) + +describe('bulkDecryptModels table forwarding', () => { + it('does not forward an EQL v2 table to the client', async () => { + const { calls, client } = recordingClient([]) + const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) + + await dynamo.bulkDecryptModels([{ pk: 'a' }], usersV2) + + expect(calls).toHaveLength(1) + expect(calls[0]?.method).toBe('bulkDecryptModels') + expect(calls[0]?.table).toBeUndefined() + }) + + it('still forwards an EQL v3 table, which the typed client requires', async () => { + const { calls, client } = recordingClient(['users_v3']) + const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) + + await dynamo.bulkDecryptModels([{ pk: 'a' }], usersV3) + + expect(calls).toHaveLength(1) + expect(calls[0]?.table).toBe(usersV3) + }) +}) + +/** + * #772 review, finding 10. + * + * The table-less v2 decrypt above is correct for the native clients, which + * derive the table from the payloads. `WasmEncryptionClient` cannot: its + * decrypt requires the table and resolves date fields from a per-table map, so + * the omitted argument reached `requireTable(undefined)` and threw a TypeError + * about reading `tableName` — a message pointing nowhere near the cause, on the + * documented entry for Deno / Workers / Supabase Edge Functions, which + * satisfies `DynamoDBEncryptionClient` structurally and so is accepted with no + * cast. + */ +describe('a client whose decrypt requires the table', () => { + /** The shape `WasmEncryptionClient` presents: declared capability, no `.audit()`. */ + function wasmShapedClient(knownTables: string[]) { + const calls: { method: string; argCount: number }[] = [] + const record = + (method: string) => + (...args: unknown[]) => { + calls.push({ method, argCount: args.length }) + // Mirrors requireTable: throws rather than returning a Result. + if (args[1] === undefined) { + throw new TypeError( + "Cannot read properties of undefined (reading 'tableName')", + ) + } + // A bare promise — NOT a thenable operation with `.audit()`. That is + // the whole point of this stub: it is the shape `WasmEncryptionClient` + // returns from `wasmResult`. The bulk methods resolve to an array, + // index-aligned with their input, as the real client does. + return Promise.resolve( + method.startsWith('bulk') ? { data: [{}] } : { data: {} }, + ) + } + const client = { + requiresTableForDecrypt: true, + getEncryptConfig: () => ({ + v: 1, + tables: Object.fromEntries(knownTables.map((t) => [t, {}])), + }), + encryptModel: record('encryptModel'), + bulkEncryptModels: record('bulkEncryptModels'), + decryptModel: record('decryptModel'), + bulkDecryptModels: record('bulkDecryptModels'), + } + return { calls, client } + } + + it('is refused for an EQL v2 table, naming the entry to use instead', () => { + const { calls, client } = wasmShapedClient([]) + const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) + + // Synchronous: the guard runs when the operation is built, so the failure + // lands at the call site rather than as a rejected promise later. + expect(() => dynamo.decryptModel({ pk: 'a' }, usersV2)).toThrow( + /wasm-inline client cannot be paired with the legacy EQL v2 table/, + ) + // Refused before the client is touched, so the user never sees the + // TypeError about `tableName`. + expect(calls).toHaveLength(0) + }) + + it('is refused on the bulk v2 path too', () => { + const { client } = wasmShapedClient([]) + const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) + + expect(() => dynamo.bulkDecryptModels([{ pk: 'a' }], usersV2)).toThrow( + /wasm-inline client cannot be paired with the legacy EQL v2 table/, + ) + }) + + /** + * #788 review, minor finding. + * + * The guard runs on all four operations, not just the two read ones, so a + * plain-JS caller reaching the write path with a v2 table hits the SAME + * message. It must therefore not be phrased for reads only — "would fail at + * the first read" names an operation that never ran. + * + * Typed callers cannot get here (the write overloads are `AnyV3Table`-only, + * pinned by `client-compat.test-d.ts`), so this is about the message a JS + * caller or a cast lands on, not about reachable behaviour changing. + */ + it('is refused on the v2 WRITE path, with a message that does not claim a read', () => { + const { calls, client } = wasmShapedClient([]) + const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) + + for (const call of [ + () => dynamo.encryptModel({ pk: 'a' } as never, usersV2 as never), + () => dynamo.bulkEncryptModels([{ pk: 'a' }] as never, usersV2 as never), + ]) { + expect(call).toThrow( + /wasm-inline client cannot be paired with the legacy EQL v2 table/, + ) + // The read-path phrasing must not survive on a write. + expect(call).not.toThrow(/would fail at the first read/) + expect(call).not.toThrow(/cannot read legacy EQL v2 items/) + } + + expect(calls).toHaveLength(0) + }) + + // v3 tables ARE forwarded the table, so this client works there — the guard + // must not turn into a blanket rejection of the wasm entry. + it('is accepted for an EQL v3 table, which is always given the table', async () => { + const { calls, client } = wasmShapedClient(['users_v3']) + const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) + + await dynamo.decryptModel({ pk: 'a' }, usersV3) + + expect(calls).toHaveLength(1) + expect(calls[0]?.argCount).toBe(2) + }) + + /** + * #788 review follow-up: the same "v3 tables are unaffected" promise, on the + * WRITE path. + * + * The encrypt operations chained `.audit()` onto the client's result + * unconditionally. The native clients return a thenable operation carrying + * it; `WasmEncryptionClient.encryptModel` returns a plain + * `Promise<WasmResult>` from `wasmResult` (it has no `.audit()` anywhere), + * so every v3 encrypt through this adapter died with + * `client.encryptModel(...).audit is not a function` — surfaced as a + * `DYNAMODB_ENCRYPTION_ERROR` failure, not a crash, so it read as a genuine + * encryption fault. The decrypt path already tolerates the bare promise via + * `resolveDecryptResult`; the write path must match. + */ + it('encrypts an EQL v3 table even though its encrypt returns a bare promise', async () => { + const { calls, client } = wasmShapedClient(['users_v3']) + const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) + + const single = await dynamo.encryptModel({ pk: 'a' } as never, usersV3) + expect(single.failure).toBeUndefined() + + const bulk = await dynamo.bulkEncryptModels([{ pk: 'a' }] as never, usersV3) + expect(bulk.failure).toBeUndefined() + + expect(calls.map((c) => c.method)).toEqual([ + 'encryptModel', + 'bulkEncryptModels', + ]) + }) + + /** + * Audit metadata has nowhere to go on this client shape, so it is dropped — + * but the encrypt must still succeed rather than failing the whole write, and + * the drop must be OBSERVABLE rather than silent. Asserting only that the + * result succeeded would pass even with the debug log deleted, and that log is + * the half that makes a missing audit record diagnosable. + */ + it('drops audit metadata observably, without failing the encrypt', async () => { + const spy = vi.spyOn(logger, 'debug').mockImplementation(() => {}) + + try { + const { client } = wasmShapedClient(['users_v3']) + const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) + + const result = await dynamo + .encryptModel({ pk: 'a' } as never, usersV3) + .audit({ metadata: { requestId: 'r-1' } }) + + expect(result.failure).toBeUndefined() + expect(spy).toHaveBeenCalledWith( + expect.stringContaining('encryptModel audit metadata ignored'), + ) + // It must name the entry to switch to, not merely report a loss. + expect(spy.mock.calls.at(-1)?.[0]).toMatch(/@cipherstash\/stack/) + } finally { + spy.mockRestore() + } + }) + + /** + * The mirror: a client that DOES carry `.audit()` must not trip the drop path. + * Without this, a "fix" that logged unconditionally — or that stopped chaining + * `.audit()` at all — would leave every test green while silently discarding + * the native clients' audit trail. + */ + it('does not report a drop when the client can carry the metadata', async () => { + const spy = vi.spyOn(logger, 'debug').mockImplementation(() => {}) + + try { + let seen: unknown + const chainable = { + audit(config: { metadata?: Record<string, unknown> }) { + seen = config.metadata + return Promise.resolve({ data: {} }) + }, + } + const client = { + getEncryptConfig: () => ({ v: 1, tables: { users_v3: {} } }), + encryptModel: () => chainable, + bulkEncryptModels: () => chainable, + decryptModel: () => Promise.resolve({ data: {} }), + bulkDecryptModels: () => Promise.resolve({ data: {} }), + } + const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) + + const result = await dynamo + .encryptModel({ pk: 'a' } as never, usersV3) + .audit({ metadata: { requestId: 'r-1' } }) + + expect(result.failure).toBeUndefined() + expect(seen).toEqual({ requestId: 'r-1' }) + expect(spy).not.toHaveBeenCalledWith( + expect.stringContaining('audit metadata ignored'), + ) + } finally { + spy.mockRestore() + } + }) + + /** + * The tolerance must not swallow a malformed result into a fake success — + * the same guard `resolveDecryptResult` applies on read. + */ + it('rejects a bare encrypt result that is not { data } or { failure }', async () => { + const client = { + requiresTableForDecrypt: true, + getEncryptConfig: () => ({ v: 1, tables: { users_v3: {} } }), + encryptModel: () => Promise.resolve('not-a-result'), + bulkEncryptModels: () => Promise.resolve('not-a-result'), + decryptModel: () => Promise.resolve({ data: {} }), + bulkDecryptModels: () => Promise.resolve({ data: {} }), + } + const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) + + const result = await dynamo.encryptModel({ pk: 'a' } as never, usersV3) + + expect(result.failure?.message).toMatch(/malformed result/) + }) +}) diff --git a/packages/stack/__tests__/empty-schemas-boundary.test.ts b/packages/stack/__tests__/empty-schemas-boundary.test.ts new file mode 100644 index 000000000..7d67ab2b2 --- /dev/null +++ b/packages/stack/__tests__/empty-schemas-boundary.test.ts @@ -0,0 +1,100 @@ +/** + * Where the compile-time non-emptiness guard stops, and the runtime one starts. + * + * A-4 widened `Encryption`'s v3 overload from a non-empty TUPLE to any array, + * moving the guard onto the `schemas` property via + * `NonEmptyV3<S> = S['length'] extends 0 ? never : S`. That guard fires only + * when the argument's TYPE has a statically known length of `0`. Once the type + * widens to `AnyV3Table[]` — a shared module export, a push-built array, a + * `.filter()` result, or a spread of any of those — `S['length']` is `number`, + * `number extends 0` is false, and the call compiles no matter how many tables + * are actually in it. + * + * So the widening deliberately traded a compile error for a runtime one on + * exactly the forms it exists to enable. `skills/stash-encryption` documents + * that boundary, and this pins the runtime half of it: the guard must reject an + * empty set BEFORE any FFI client is constructed, on every entry point. + * + * The compile-time half is pinned in `encryption-overloads.test-d.ts`. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AnyV3Table } from '@/eql/v3' +import { Encryption } from '@/index' +import { encryptedColumn, encryptedTable } from '@/schema' + +vi.mock('@cipherstash/protect-ffi', () => ({ + newClient: vi.fn(async () => ({ __mock: 'client' })), +})) + +import * as ffi from '@cipherstash/protect-ffi' + +const EMPTY_SCHEMAS = /At least one encryptedTable must be provided/ + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('empty schema sets are refused at runtime', () => { + // The forms below all COMPILE — that is the point. Each is a shape the A-4 + // widening admits, and each is empty at runtime. + it('rejects a shared array annotated AnyV3Table[] that is empty', async () => { + const shared: AnyV3Table[] = [] + + await expect(Encryption({ schemas: shared })).rejects.toThrow(EMPTY_SCHEMAS) + }) + + it('rejects a ReadonlyArray that is empty', async () => { + const frozen: ReadonlyArray<AnyV3Table> = [] + + await expect(Encryption({ schemas: frozen })).rejects.toThrow(EMPTY_SCHEMAS) + }) + + it('rejects a spread of an empty array — a literal at the call site', async () => { + // Spreading erases the tuple length, so even an array literal here carries + // no compile-time non-emptiness. This is the form most likely to be read as + // "a literal, therefore checked". + const src: AnyV3Table[] = [] + + await expect(Encryption({ schemas: [...src] })).rejects.toThrow( + EMPTY_SCHEMAS, + ) + }) + + it('rejects an array emptied by a filter', async () => { + const all: AnyV3Table[] = [] + + await expect( + Encryption({ schemas: all.filter(() => false) }), + ).rejects.toThrow(EMPTY_SCHEMAS) + }) + + // The v2 builders reach the same guard — it predates the v3 typed client and + // is not part of the overload machinery. + it('rejects an empty v2 schema set', async () => { + const v2: Array<ReturnType<typeof encryptedTable>> = [] + + await expect(Encryption({ schemas: v2 })).rejects.toThrow(EMPTY_SCHEMAS) + }) + + it('refuses before constructing the FFI client, so no credentials are needed', async () => { + const shared: AnyV3Table[] = [] + + await expect(Encryption({ schemas: shared })).rejects.toThrow(EMPTY_SCHEMAS) + expect(ffi.newClient).not.toHaveBeenCalled() + }) + + // Control: the same call shape with one table gets past the guard, proving the + // assertions above fail on emptiness rather than on the array form itself. + it('accepts a non-empty array of the same widened type', async () => { + const shared: AnyV3Table[] = [] + shared.push( + encryptedTable('users', { + email: encryptedColumn('email'), + }) as unknown as AnyV3Table, + ) + + await expect(Encryption({ schemas: shared })).resolves.toBeDefined() + expect(ffi.newClient).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/stack/__tests__/encrypt-lock-context-guards.test.ts b/packages/stack/__tests__/encrypt-lock-context-guards.test.ts index 981845eb6..2162b359a 100644 --- a/packages/stack/__tests__/encrypt-lock-context-guards.test.ts +++ b/packages/stack/__tests__/encrypt-lock-context-guards.test.ts @@ -19,6 +19,8 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { EncryptionClient } from '@/encryption' +import type { EncryptionClientFor } from '@/encryption/v3' import { encryptedTable as encryptedTableV3, types } from '@/eql/v3' import { LockContext } from '@/identity' import { Encryption } from '@/index' @@ -53,8 +55,8 @@ const usersV3 = encryptedTableV3('users_v3', { // biome-ignore lint/suspicious/noExplicitAny: test helper reads the Result union const failure = (result: any) => result.failure -let clientV2: Awaited<ReturnType<typeof Encryption>> -let clientV3: Awaited<ReturnType<typeof Encryption>> +let clientV2: EncryptionClient +let clientV3: EncryptionClientFor<readonly [typeof usersV3]> beforeEach(async () => { vi.clearAllMocks() @@ -66,8 +68,13 @@ beforeEach(async () => { clientV3 = await Encryption({ schemas: [usersV3] }) }) -const clientFor = (variant: 'v2' | 'v3') => - variant === 'v2' ? clientV2 : clientV3 +// The two clients have different `encrypt` signatures — `clientV3` pins the +// plaintext to each column's domain — so the shared `describe.each` call below +// cannot resolve against their union. The guards under test are runtime value +// checks that predate both surfaces and behave identically on each, so the +// dispatch (and only the dispatch) erases to the nominal shape. +const clientFor = (variant: 'v2' | 'v3'): EncryptionClient => + variant === 'v2' ? clientV2 : (clientV3 as unknown as EncryptionClient) describe.each([ ['v2', { column: users.score, table: users }], diff --git a/packages/stack/__tests__/encrypt-query-searchable-json.test.ts b/packages/stack/__tests__/encrypt-query-searchable-json.test.ts index bc99fcb4f..d65e791ed 100644 --- a/packages/stack/__tests__/encrypt-query-searchable-json.test.ts +++ b/packages/stack/__tests__/encrypt-query-searchable-json.test.ts @@ -1,11 +1,10 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' import { createMockLockContext, expectFailure, unwrapResult } from './fixtures' -type EncryptionClient = Awaited<ReturnType<typeof Encryption>> - const documents = encryptedTable('documents', { metadata: types.Json('metadata'), }) @@ -21,10 +20,19 @@ function expectContainment(value: unknown): void { } describe('encryptQuery with searchableJson', () => { + // NOTE: this suite holds the client through the NOMINAL surface on purpose. + // The typed client derives `encryptQuery`'s plaintext from the column's domain, + // so every query type on a `types.Json()` column is typed `JsonDocument` — but + // the searchable-JSON query types take a JSONPath string, a `{ path, value }` + // pair, or a bare scalar. This file exercises exactly those, so typing it + // against the typed client would mean casting away the argument type at every + // call and hiding the gap. Cast once, here, where it is visible and explained. let client: EncryptionClient beforeAll(async () => { - client = await Encryption({ schemas: [documents] }) + client = (await Encryption({ + schemas: [documents], + })) as unknown as EncryptionClient }) it('infers a selector hash for string plaintext', async () => { diff --git a/packages/stack/__tests__/encrypt-query-stevec.test.ts b/packages/stack/__tests__/encrypt-query-stevec.test.ts index 54d9f1396..894592878 100644 --- a/packages/stack/__tests__/encrypt-query-stevec.test.ts +++ b/packages/stack/__tests__/encrypt-query-stevec.test.ts @@ -1,11 +1,10 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' import { expectFailure, unwrapResult } from './fixtures' -type EncryptionClient = Awaited<ReturnType<typeof Encryption>> - const documents = encryptedTable('documents', { metadata: types.Json('metadata'), }) @@ -27,10 +26,20 @@ function expectOrderingTerm(value: unknown): void { } describe('encryptQuery with protect-ffi 0.30 SteVec operations', () => { + // NOTE: this suite holds the client through the NOMINAL surface on purpose. + // The typed client derives `encryptQuery`'s plaintext from the column's domain, + // so every query type on a `types.Json()` column is typed `JsonDocument` — but + // the SteVec query types take a JSONPath string (`steVecSelector`), a + // `{ path, value }` pair (`steVecValueSelector`) or a bare scalar + // (`steVecTerm`). This file exercises exactly those, so typing it against the + // typed client would mean casting away the argument type at every call and + // hiding the gap. Cast once, here, where it is visible and explained. let client: EncryptionClient beforeAll(async () => { - client = await Encryption({ schemas: [documents, plain] }) + client = (await Encryption({ + schemas: [documents, plain], + })) as unknown as EncryptionClient }) it('returns a bare selector hash for a JSONPath', async () => { diff --git a/packages/stack/__tests__/encryption-overloads.test-d.ts b/packages/stack/__tests__/encryption-overloads.test-d.ts new file mode 100644 index 000000000..aae0fc789 --- /dev/null +++ b/packages/stack/__tests__/encryption-overloads.test-d.ts @@ -0,0 +1,248 @@ +/** + * Type-level contract for the `Encryption` overload pair. + * + * `Encryption` is overloaded — an all-v3 schema tuple yields the typed client, + * everything else yields the nominal one — and the two are NOT mutually + * assignable. Overload selection is therefore load-bearing public API, and none + * of it is exercised by a runtime test. Each case below was a real defect found + * in review (#772): a call that type-checked as the typed client but returned + * the nominal one at runtime, a schema set the types accepted and the runtime + * threw on, and a `ReturnType` idiom that silently resolves to the wrong client. + */ +import { describe, expectTypeOf, it } from 'vitest' +import { Encryption, type EncryptionClient } from '@/encryption' +import { + type EncryptionClientFor, + encryptedTable, + type TypedEncryptionClient, +} from '@/encryption/v3' +import { type AnyV3Table, types } from '@/eql/v3' +import { encryptedColumn, encryptedTable as encryptedTableV2 } from '@/schema' + +const users = encryptedTable('users', { + email: types.TextEq('email'), + createdAt: types.TimestampOrd('created_at'), +}) + +const usersV2 = encryptedTableV2('users_v2', { + email: encryptedColumn('email').equality(), +}) + +describe('overload selection', () => { + it('an all-v3 schema tuple yields the typed client', async () => { + const client = await Encryption({ schemas: [users] }) + expectTypeOf(client).toEqualTypeOf< + TypedEncryptionClient<readonly [typeof users]> + >() + }) + + it('a v2 schema set yields the nominal client', async () => { + const client = await Encryption({ schemas: [usersV2] }) + expectTypeOf(client).toEqualTypeOf<EncryptionClient>() + }) + + // S-6: `readonly []` satisfies `readonly AnyV3Table[]`, so an empty schema set + // used to compile and then throw at runtime. Both overloads now require at + // least one table. + it('rejects an empty schema set', () => { + // @ts-expect-error - at least one encryptedTable is required + Encryption({ schemas: [] }) + }) + + // ...but only when the ARGUMENT'S TYPE has a statically known length of 0. + // `NonEmptyV3<S>` keys off `S['length'] extends 0`, so once the type widens to + // `AnyV3Table[]` the length is `number` and emptiness stops being visible — + // including for a literal at the call site, because a spread erases the tuple. + // These compile ON PURPOSE: rejecting them is what the A-4 tuple constraint + // did, and it broke every non-literal caller. The runtime guard is what + // catches them; `empty-schemas-boundary.test.ts` pins that half, and + // `skills/stash-encryption` documents the split for users. + // A generic passthrough is the shape `EncryptionClientFor` and the skill both + // advertise ("code that is generic over its schemas"), and it is the one form + // a WIDENING change must not break. It compiled before A-4 and must still. + // + // A deferred conditional on the property is what broke it: `S` is assignable + // to `NonEmptyV3<S>` only if it is assignable to BOTH branches, and one branch + // is `never`. The emptiness rejection does not need it — overload 2's + // `AtLeastOneCsTable` rejects `[]` on its own. + it('accepts schemas from a generic wrapper function', async () => { + async function makeTypedClient< + S extends readonly [AnyV3Table, ...AnyV3Table[]], + >(schemas: S) { + return await Encryption({ schemas }) + } + + expectTypeOf(await makeTypedClient([users])).toEqualTypeOf< + TypedEncryptionClient<readonly [typeof users]> + >() + + // A wrapper generic over a LOOSE `readonly AnyV3Table[]` is still rejected, + // here and on the pre-A-4 signature alike. That is the honest answer rather + // than a gap: such an `S` admits `readonly []`, so the wrapper cannot + // promise what `Encryption` requires. Constrain it to a non-empty tuple, as + // above, and it compiles. + async function makeLooseClient<S extends readonly AnyV3Table[]>( + schemas: S, + ) { + // @ts-expect-error - a loose `S` cannot prove it is non-empty + return await Encryption({ schemas }) + } + void makeLooseClient + }) + + it('accepts arrays whose type cannot prove non-emptiness', () => { + const shared: AnyV3Table[] = [] + expectTypeOf(Encryption).toBeCallableWith({ schemas: shared }) + + const frozen: ReadonlyArray<AnyV3Table> = [] + expectTypeOf(Encryption).toBeCallableWith({ schemas: frozen }) + + expectTypeOf(Encryption).toBeCallableWith({ schemas: [...shared] }) + expectTypeOf(Encryption).toBeCallableWith({ + schemas: shared.filter(() => false), + }) + }) + + // A-4: closing S-6 with a non-empty TUPLE constraint rejected every schema + // array that is not a literal, which is most real code — a shared module + // export, anything built from introspection, anything `readonly`. The + // non-emptiness check moved to the `schemas` property so these compile again + // while `[]` above still does not. One case per form that was broken. + it('accepts schema arrays that are not literals', async () => { + const shared: AnyV3Table[] = [users] + expectTypeOf(await Encryption({ schemas: shared })).toEqualTypeOf< + TypedEncryptionClient<AnyV3Table[]> + >() + + // `ReadonlyArray` is the form `@cipherstash/prisma-next` exposes publicly. + const frozen: ReadonlyArray<AnyV3Table> = [users] + expectTypeOf(await Encryption({ schemas: frozen })).toEqualTypeOf< + TypedEncryptionClient<readonly AnyV3Table[]> + >() + + const built: AnyV3Table[] = [] + built.push(users) + expectTypeOf(await Encryption({ schemas: built })).toEqualTypeOf< + TypedEncryptionClient<AnyV3Table[]> + >() + + expectTypeOf(await Encryption({ schemas: [...shared] })).toEqualTypeOf< + TypedEncryptionClient<AnyV3Table[]> + >() + }) + + // The widening must not cost the literal path its precision — that typing is + // the entire reason the typed client exists. `const` inference has to survive. + it('keeps per-column typing on the literal path', async () => { + const client = await Encryption({ schemas: [users] }) + + expectTypeOf(client.encrypt).toBeCallableWith('a@b.com', { + table: users, + column: users.email, + }) + // @ts-expect-error - `email` is a text domain, not a number + client.encrypt(123, { table: users, column: users.email }) + // @ts-expect-error - `createdAt` is a timestamp domain, not a string + client.encrypt('2020-01-01', { table: users, column: users.createdAt }) + }) + + // S-4: `eqlVersion: 2` selects the NOMINAL overload, because the typed client + // cannot author v3 columns in v2 mode. The types used to claim the typed + // client, so `decryptModel(row, table, lockContext)` compiled and then + // silently dropped `table` and `lockContext`. + // + // Over an all-v3 schema set this combination is now REJECTED AT RUNTIME + // (#772 review, finding 8) — v2 wire into `eql_v3_*` columns is a + // contradiction, and `EncryptionV3` used to force `eqlVersion: 3` precisely + // to stop it. This assertion therefore records overload resolution only; the + // call itself throws. `init-strategy.test.ts` pins the throw. + it('forcing eqlVersion 2 selects the nominal overload (and is refused at runtime for v3 schemas)', async () => { + const client = await Encryption({ + schemas: [users], + config: { eqlVersion: 2 }, + }) + expectTypeOf(client).toEqualTypeOf<EncryptionClient>() + // The typed-only three-arg decrypt is therefore not available — which is + // exactly what the runtime does. + // @ts-expect-error - the nominal client takes the model alone + client.decryptModel({ email: 'x' }, users) + }) + + it('an explicit eqlVersion 3 keeps the typed client', async () => { + const client = await Encryption({ + schemas: [users], + config: { eqlVersion: 3 }, + }) + expectTypeOf(client).toEqualTypeOf< + TypedEncryptionClient<readonly [typeof users]> + >() + }) +}) + +describe('naming the client type', () => { + it('EncryptionClientFor names the typed client for a v3 tuple', async () => { + const client: EncryptionClientFor<readonly [typeof users]> = + await Encryption({ schemas: [users] }) + expectTypeOf(client).toEqualTypeOf< + TypedEncryptionClient<readonly [typeof users]> + >() + }) + + // A-6: the form generic code needs — an integration adapter that builds its + // table per test family cannot name a tuple. This must track `Encryption`'s + // own constraint: while `EncryptionClientFor` still required a non-empty + // TUPLE it fell through to the nominal client here, silently handing the + // wrong type to exactly the callers the widening above exists to serve. + it('EncryptionClientFor names the typed client for a loose v3 array', () => { + expectTypeOf<EncryptionClientFor<readonly AnyV3Table[]>>().toEqualTypeOf< + TypedEncryptionClient<readonly AnyV3Table[]> + >() + }) + + it('EncryptionClientFor falls back to the nominal client', () => { + expectTypeOf< + EncryptionClientFor<readonly [typeof usersV2]> + >().toEqualTypeOf<EncryptionClient>() + + // `never extends X` is true, so an empty tuple satisfies "every element is + // a v3 table" — the emptiness arm has to be checked inside the v3 branch. + expectTypeOf< + EncryptionClientFor<readonly []> + >().toEqualTypeOf<EncryptionClient>() + }) + + // S-2: `ReturnType` reads the LAST overload, so this idiom resolves to the + // nominal client no matter what schemas you pass. Overload order cannot + // satisfy both forms — putting the nominal signature first mis-resolves v3 + // schemas instead, because a v3 table structurally satisfies `BuildableTable`. + // `EncryptionClientFor` above is the supported idiom; this pins the trap so it + // cannot start silently resolving differently. + it('ReturnType<typeof Encryption> resolves to the NOMINAL client', () => { + expectTypeOf< + Awaited<ReturnType<typeof Encryption>> + >().toEqualTypeOf<EncryptionClient>() + }) +}) + +describe('reading legacy EQL v2 models through a typed client', () => { + // The compatibility promise: a v3-configured client must read rows written + // before the upgrade. Their table is not — and cannot be — a member of the + // client's v3 schema tuple, so the table-less call has to type-check. + it('accepts a table-less decryptModel', async () => { + const client = await Encryption({ schemas: [users] }) + expectTypeOf(client.decryptModel).toBeCallableWith({ + pk: 'a', + email: { k: 'ct', v: 2, c: 'ciphertext', i: { t: 'legacy', c: 'email' } }, + }) + expectTypeOf(client.bulkDecryptModels).toBeCallableWith([ + { pk: 'a', email: { k: 'ct', v: 2, c: 'x', i: { t: 'l', c: 'email' } } }, + ]) + }) + + it('still rejects a table this client was not built with', async () => { + const client = await Encryption({ schemas: [users] }) + const unregistered = encryptedTable('other', { note: types.TextEq('note') }) + // @ts-expect-error - `other` is not a member of the client's schema tuple + client.decryptModel({ note: 'x' }, unregistered) + }) +}) diff --git a/packages/stack/__tests__/helpers/process-free-realm.mjs b/packages/stack/__tests__/helpers/process-free-realm.mjs new file mode 100644 index 000000000..cf1a8be8d --- /dev/null +++ b/packages/stack/__tests__/helpers/process-free-realm.mjs @@ -0,0 +1,76 @@ +/** + * Evaluate an emitted ESM file graph in a realm with NO `process` global — + * i.e. a Worker or a Deno isolate. + * + * Uses `node:vm`'s `SourceTextModule` with a linker that COMPILES each relative + * import into the same sandbox context, rather than `import()`ing it in the host + * realm (which would hand the module the host's `process` and prove nothing). + * Bare specifiers are rejected. The graphs this is pointed at have none today + * only because everything they reach is bundled (`noExternal` in + * `packages/stack/tsup.config.ts`) — a property of the build config, not of + * module resolution. So a new one is a SIGNAL, not automatically a defect: if + * it is a legitimate external the target runtime resolves, allow it here. + * + * Run with `node --experimental-vm-modules`; callers spawn it that way, as a + * CHILD PROCESS, so no vitest configuration or flag is involved. + * + * Usage: node --experimental-vm-modules process-free-realm.mjs <entry.js> + * Exits 0 and prints `OK <n> exports`, or exits 1 and prints the failure. + */ +import { readFileSync } from 'node:fs' +import { dirname, resolve as resolvePath } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import vm from 'node:vm' + +async function loadInProcessFreeRealm(entryPath) { + // Deliberately minimal: `console` for diagnostics and the handful of globals + // a Worker genuinely has. NO `process`, NO `require`, NO `Buffer`. + const context = vm.createContext({ + console, + TextEncoder, + TextDecoder, + URL, + WebAssembly, + atob, + btoa, + }) + const cache = new Map() + + const compile = (file) => { + const cached = cache.get(file) + if (cached) return cached + const mod = new vm.SourceTextModule(readFileSync(file, 'utf8'), { + context, + identifier: pathToFileURL(file).href, + initializeImportMeta(meta) { + meta.url = pathToFileURL(file).href + }, + }) + cache.set(file, mod) + return mod + } + + const link = (specifier, referencing) => { + if (!specifier.startsWith('.') && !specifier.startsWith('/')) { + throw new Error( + `unexpected bare specifier "${specifier}" in ${referencing.identifier} — this graph is bundled (tsup \`noExternal\`) and should have none. If this is a legitimate new external, allow it in this harness.`, + ) + } + const base = dirname(fileURLToPath(referencing.identifier)) + return compile(resolvePath(base, specifier)) + } + + const entry = compile(entryPath) + await entry.link(link) + await entry.evaluate() + return entry.namespace +} + +const [, , entryPath] = process.argv +try { + const namespace = await loadInProcessFreeRealm(entryPath) + console.log(`OK ${Object.keys(namespace).length} exports`) +} catch (error) { + console.error(`${error.constructor.name}: ${error.message}`) + process.exit(1) +} diff --git a/packages/stack/__tests__/init-strategy.test.ts b/packages/stack/__tests__/init-strategy.test.ts index ddef9f0f5..b9357193d 100644 --- a/packages/stack/__tests__/init-strategy.test.ts +++ b/packages/stack/__tests__/init-strategy.test.ts @@ -228,8 +228,23 @@ describe('Encryption config.eqlVersion', () => { expect(lastNewClientOpts().eqlVersion).toBe(3) }) - it('lets an explicit eqlVersion 2 override v3 auto-detection (migration escape hatch)', async () => { - await Encryption({ schemas: [v3Table()], config: { eqlVersion: 2 } }) + // #772 review, finding 8. This used to be honoured, and the resulting client + // wrote `eql_v2_encrypted` payloads into `eql_v3_*` columns with no + // diagnostic at any layer — the type surface types it as the nominal client, + // which matches what the runtime returns, so nothing disagreed loudly enough + // to notice. + it('rejects an explicit eqlVersion 2 over a v3 schema set before constructing the FFI client', async () => { + await expect( + Encryption({ schemas: [v3Table()], config: { eqlVersion: 2 } }), + ).rejects.toThrow(/entirely EQL v3/) + + expect(ffi.newClient).not.toHaveBeenCalled() + }) + + // The escape hatch survives where it is actually used: minting v2 wire from + // a v2 schema set, which is how the v2-read-compat fixtures are produced. + it('still honours an explicit eqlVersion 2 for a v2 schema set', async () => { + await Encryption({ schemas: [users], config: { eqlVersion: 2 } }) expect(lastNewClientOpts().eqlVersion).toBe(2) }) @@ -254,15 +269,20 @@ describe('Encryption config.eqlVersion', () => { expect(lastNewClientOpts().eqlVersion).toBe(3) }) - it('EncryptionV3 overrides an explicit eqlVersion — v3 is an invariant', async () => { - // A v2-mode client cannot resolve v3 concrete-type columns (every encrypt - // fails inside the FFI), so EncryptionV3 pins the wire format rather than - // honouring a caller's eqlVersion. - await EncryptionV3({ - schemas: [v3Table() as never], - config: { eqlVersion: 2 }, - }) + it('EncryptionV3 rejects an explicit eqlVersion 2 identically', async () => { + // Post-collapse `EncryptionV3` IS `Encryption` (a deprecated alias), so it + // no longer independently pins the wire format. The invariant it used to + // enforce by forcing `eqlVersion: 3` now lives in `resolveEqlVersion`, so + // the outcome for a caller upgrading from the old `EncryptionV3` is the + // same in the case that matters: the contradiction is refused rather than + // silently building a v2-wire client over v3 concrete domains. + await expect( + EncryptionV3({ + schemas: [v3Table() as never], + config: { eqlVersion: 2 }, + }), + ).rejects.toThrow(/entirely EQL v3/) - expect(lastNewClientOpts().eqlVersion).toBe(3) + expect(ffi.newClient).not.toHaveBeenCalled() }) }) diff --git a/packages/stack/__tests__/json-protect.test.ts b/packages/stack/__tests__/json-protect.test.ts index ea55f4213..34e3eb2b1 100644 --- a/packages/stack/__tests__/json-protect.test.ts +++ b/packages/stack/__tests__/json-protect.test.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { LockContext } from '@/identity' import { Encryption } from '@/index' import { encryptedColumn, encryptedField, encryptedTable } from '@/schema' @@ -33,7 +34,7 @@ type User = { } } -let protectClient: Awaited<ReturnType<typeof Encryption>> +let protectClient: EncryptionClient beforeAll(async () => { protectClient = await Encryption({ diff --git a/packages/stack/__tests__/lock-context-wiring.test.ts b/packages/stack/__tests__/lock-context-wiring.test.ts index cf4b967cb..9a578c014 100644 --- a/packages/stack/__tests__/lock-context-wiring.test.ts +++ b/packages/stack/__tests__/lock-context-wiring.test.ts @@ -44,6 +44,7 @@ vi.mock('@cipherstash/protect-ffi', () => ({ })) import * as ffi from '@cipherstash/protect-ffi' +import type { EncryptionClient } from '@/encryption' const users = encryptedTable('users', { email: encryptedColumn('email').equality(), @@ -78,7 +79,7 @@ function unwrap(result: any) { // biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args const lastOpts = (fn: any) => fn.mock.calls.at(-1)[1] -let client: Awaited<ReturnType<typeof Encryption>> +let client: EncryptionClient beforeEach(async () => { vi.clearAllMocks() diff --git a/packages/stack/__tests__/logger-edge-safety.test.ts b/packages/stack/__tests__/logger-edge-safety.test.ts new file mode 100644 index 000000000..87e607f40 --- /dev/null +++ b/packages/stack/__tests__/logger-edge-safety.test.ts @@ -0,0 +1,48 @@ +/** + * `@cipherstash/stack/adapter-kit` re-exports this package's `logger` + * (`src/adapter-kit.ts:60`), and three first-party adapters value-import + * adapter-kit: `packages/stack-supabase/src/column-map.ts:1`, + * `packages/stack-drizzle/src/column.ts:1`, + * `packages/prisma-next/src/exports/column-types.ts:19`. A realm with no + * `process` binding turns an unguarded module-scope `process.env` read in the + * logger into a `ReferenceError` at import time on exactly the runtimes those + * builds exist to serve. + * + * This asserts the fix rather than a proxy for it: delete the `typeof process` + * guard from `src/utils/logger/index.ts`, rebuild, and this test fails. + * + * It reads `dist/`, so it SKIPS when the package has not been built — run + * `pnpm --filter @cipherstash/stack build` first for it to mean anything. + * (`turbo.json` wires `test` to `build`, so the turbo path cannot skip it; a + * bare `pnpm --filter … test` on a clean checkout still can.) The + * portable-entry plan will point the same harness at the WASM entry. + */ +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { describe, expect, it } from 'vitest' + +const execFileAsync = promisify(execFile) +const testsDir = fileURLToPath(new URL('.', import.meta.url)) +const harness = resolve(testsDir, 'helpers/process-free-realm.mjs') +const emittedEntry = resolve(testsDir, '../dist/adapter-kit.js') + +describe.skipIf(!existsSync(emittedEntry))( + 'the emitted adapter-kit seam imports without a process global', + () => { + it('evaluates dist/adapter-kit.js in a process-free realm', async () => { + // Rejects on a non-zero exit, so the harness's own failure line + // (`ReferenceError: process is not defined`) surfaces as the assertion + // failure. + const { stdout } = await execFileAsync(process.execPath, [ + '--experimental-vm-modules', + harness, + emittedEntry, + ]) + + expect(stdout.trim()).toMatch(/^OK \d+ exports$/) + }, 30_000) + }, +) diff --git a/packages/stack/__tests__/number-protect.test.ts b/packages/stack/__tests__/number-protect.test.ts index 82222377a..8bc084982 100644 --- a/packages/stack/__tests__/number-protect.test.ts +++ b/packages/stack/__tests__/number-protect.test.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import { beforeAll, describe, expect, it, test } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { LockContext } from '@/identity' import { Encryption } from '@/index' import { encryptedColumn, encryptedField, encryptedTable } from '@/schema' @@ -29,7 +30,7 @@ type User = { } } -let protectClient: Awaited<ReturnType<typeof Encryption>> +let protectClient: EncryptionClient beforeAll(async () => { protectClient = await Encryption({ diff --git a/packages/stack/__tests__/protect-ops.test.ts b/packages/stack/__tests__/protect-ops.test.ts index df9c40da4..c92f4595e 100644 --- a/packages/stack/__tests__/protect-ops.test.ts +++ b/packages/stack/__tests__/protect-ops.test.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { LockContext } from '@/identity' import { Encryption } from '@/index' import { encryptedColumn, encryptedTable } from '@/schema' @@ -18,7 +19,7 @@ type User = { number?: number } -let protectClient: Awaited<ReturnType<typeof Encryption>> +let protectClient: EncryptionClient beforeAll(async () => { protectClient = await Encryption({ diff --git a/packages/stack/__tests__/resolve-eql-version.test.ts b/packages/stack/__tests__/resolve-eql-version.test.ts new file mode 100644 index 000000000..2ec1d0c5c --- /dev/null +++ b/packages/stack/__tests__/resolve-eql-version.test.ts @@ -0,0 +1,137 @@ +/** + * The wire-format detection matrix behind `Encryption({ schemas })`. + * + * `resolveEqlVersion` decides, from the schema set alone, which EQL wire format + * the FFI client will emit. It carries an `@internal exported for unit-test + * coverage of the detection matrix` marker — this file is that coverage. It had + * none until now, which mattered: the only place the v3 wire choice was + * exercised was `integration/shared/v2-decrypt-compat.integration.test.ts`, and + * that suite needs live ZeroKMS credentials, so a regression here was invisible + * to `pnpm test`. + * + * Why a silent regression here is dangerous rather than merely wrong: if v3 + * detection broke, `resolveEqlVersion` would return `undefined` (the FFI's v2 + * default) instead of throwing, so a v3-schema client would quietly start + * writing v2 wire into `eql_v3_*` columns. Every v2-read compatibility test + * would keep passing, because reading v2 is exactly what a v2-mode client does + * natively. Pin the mapping directly. + * + * Credential-free by construction: `resolveEqlVersion` is pure, and inspects + * only `build()` output and the `buildColumnKeyMap` marker. + */ +import { describe, expect, it } from 'vitest' +import { resolveEqlVersion } from '@/encryption' +import { encryptedTable as encryptedTableV3, types as typesV3 } from '@/eql/v3' +// The deprecated v2 authoring builders remain for reading/migrating legacy data. +import { encryptedColumn, encryptedTable } from '@/schema' + +const usersV3 = encryptedTableV3('users_v3', { + email: typesV3.TextSearch('email'), +}) + +const ordersV3 = encryptedTableV3('orders_v3', { + total: typesV3.IntegerOrd('total'), +}) + +const usersV2 = encryptedTable('users_v2', { + email: encryptedColumn('email').equality(), +}) + +const documentsV2SteVec = encryptedTable('documents_v2', { + metadata: encryptedColumn('metadata').searchableJson(), +}) + +describe('resolveEqlVersion — wire format detection', () => { + it('resolves an all-v3 schema set to 3', () => { + expect(resolveEqlVersion([usersV3])).toBe(3) + }) + + it('resolves several v3 tables to 3', () => { + expect(resolveEqlVersion([usersV3, ordersV3])).toBe(3) + }) + + it('leaves a v2 scalar schema set on the FFI default by returning undefined', () => { + // NOT `2`: the FFI's own default is v2, and `undefined` is what the client + // passes through to mean "don't override it". + expect(resolveEqlVersion([usersV2])).toBeUndefined() + }) + + it('throws on a mixed v2 + v3 schema set — one client emits one wire format', () => { + expect(() => resolveEqlVersion([usersV3, usersV2])).toThrow( + /cannot mix EQL v2 and EQL v3 tables in one client/, + ) + }) + + it('throws on a mixed set regardless of schema order', () => { + expect(() => resolveEqlVersion([usersV2, usersV3])).toThrow( + /cannot mix EQL v2 and EQL v3 tables in one client/, + ) + }) +}) + +describe('resolveEqlVersion — legacy v2 searchable JSON', () => { + it('throws for a v2 ste_vec column, which protect-ffi 0.30 cannot emit', () => { + expect(() => resolveEqlVersion([documentsV2SteVec])).toThrow( + /searchableJson\(\) on the legacy EQL v2 schema is not supported/, + ) + }) + + it('still throws when an explicit eqlVersion is supplied', () => { + // The explicit escape hatch bypasses DETECTION, not validation — otherwise + // it would emit v3 data into an eql_v2 column. + expect(() => resolveEqlVersion([documentsV2SteVec], 2)).toThrow( + /searchableJson\(\) on the legacy EQL v2 schema is not supported/, + ) + }) +}) + +describe('resolveEqlVersion — explicit config.eqlVersion', () => { + // #772 review, finding 8. `EncryptionV3` used to force `eqlVersion: 3` over + // whatever the caller passed, with a comment saying it existed to stop + // exactly this. It is now a bare alias of `Encryption`, so the override is + // gone and nothing rejected the combination — the client built v2-wire over + // v3 concrete domains, which writes an `eql_v2_encrypted` payload into an + // `eql_v3_*` column. `resolveEqlVersion` already throws for a mixed set and + // for v2 SteVec; this was the one contradiction it let through. + it('rejects an explicit 2 over an all-v3 schema set', () => { + expect(() => resolveEqlVersion([usersV3], 2)).toThrow( + /cannot emit EQL v2 wire for a schema set that is entirely EQL v3/, + ) + }) + + it('rejects an explicit 2 over several v3 tables', () => { + expect(() => resolveEqlVersion([usersV3, ordersV3], 2)).toThrow( + /entirely EQL v3/, + ) + }) + + // The escape hatch itself survives, and this is the shape that actually uses + // it: `integration/shared/v2-decrypt-compat.integration.test.ts` mints its v2 + // fixtures from a v2 schema set. Nothing mints v2 wire from a v3 table. + it('still honours an explicit 2 over a v2 schema set', () => { + expect(resolveEqlVersion([usersV2], 2)).toBe(2) + }) + + it('honours an explicit 3 over a v2 schema set', () => { + expect(resolveEqlVersion([usersV2], 3)).toBe(3) + }) + + it('honours an explicit 3 over a v3 schema set', () => { + expect(resolveEqlVersion([usersV3], 3)).toBe(3) + }) + + // An empty set never reaches here through `Encryption` — the caller throws + // first — but the function is exported, so pin that it does not invent a + // wire version for a set with no evidence in it either way. + it('does not reject an explicit 2 for an empty schema set', () => { + expect(resolveEqlVersion([], 2)).toBe(2) + }) + + it('does not let an explicit version rescue a mixed schema set', () => { + // Mixing is unfixable by declaration: the two generations target different + // column types, so no single wire format serves both. + expect(() => resolveEqlVersion([usersV3, usersV2], 3)).toThrow( + /cannot mix EQL v2 and EQL v3 tables in one client/, + ) + }) +}) diff --git a/packages/stack/__tests__/typed-client-init-wire-version.test.ts b/packages/stack/__tests__/typed-client-init-wire-version.test.ts new file mode 100644 index 000000000..e535cc001 --- /dev/null +++ b/packages/stack/__tests__/typed-client-init-wire-version.test.ts @@ -0,0 +1,96 @@ +/** + * The typed client's `init` passthrough must not reopen the door A-8 closed. + * + * A-7 added `init` to the typed EQL v3 client so that a caller holding it + * through its declared `EncryptionClient` type could not hit + * `TypeError: init is not a function`. But `EncryptionClient.init` takes its own + * `eqlVersion?: 2 | 3`, and forwards `undefined` straight to `newClient`, where + * the FFI's default is EQL **v2**. `resolveEqlVersion` — which refuses exactly + * that combination — is only ever consulted by `Encryption`, never by `init`. + * + * So a bare passthrough lets a v3 client be silently re-initialised into v2 wire + * while keeping the typed v3 surface: the same contradiction A-8 rejects at + * construction, reachable one method call later. + * + * const client = await Encryption({ schemas: [users] }) // eqlVersion: 3 + * await client.init({ encryptConfig }) // eqlVersion: undefined -> v2 + * + * `typed-client-nominal-parity.test.ts` cannot catch this: it stubs `init` out + * entirely and asserts only that it was called. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@cipherstash/protect-ffi', () => ({ + newClient: vi.fn(async () => ({ __mock: 'client' })), +})) + +import * as ffi from '@cipherstash/protect-ffi' +import type { EncryptionClient } from '@/encryption' +import { encryptedTable, types } from '@/encryption/v3' +import { Encryption } from '@/index' +import { buildEncryptConfig } from '@/schema' + +const users = encryptedTable('users', { email: types.TextSearch('email') }) + +// biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args +const newClientCalls = () => (ffi.newClient as any).mock.calls +const lastEqlVersion = () => newClientCalls().at(-1)[0].eqlVersion + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('typed client init keeps the v3 wire format', () => { + it('constructs at eqlVersion 3', async () => { + await Encryption({ schemas: [users] }) + + expect(lastEqlVersion()).toBe(3) + }) + + it('stays at eqlVersion 3 when re-initialised without one', async () => { + const client = await Encryption({ schemas: [users] }) + expect(lastEqlVersion()).toBe(3) + + await (client as unknown as EncryptionClient).init({ + encryptConfig: buildEncryptConfig(users), + }) + + // Without pinning, this is `undefined` — the FFI's v2 default — leaving a + // typed v3 surface over a client emitting v2 payloads into eql_v3_* columns. + expect(newClientCalls()).toHaveLength(2) + expect(lastEqlVersion()).toBe(3) + }) + + it('refuses an explicit eqlVersion 2 on re-init, as construction does', async () => { + const client = await Encryption({ schemas: [users] }) + + // `Encryption` throws for this combination, but `init` declares + // `Promise<Result<…>>` — the Result shape is contract, so this refuses with + // a failure rather than rejecting. Either way it never reaches the FFI. + const result = await (client as unknown as EncryptionClient).init({ + encryptConfig: buildEncryptConfig(users), + eqlVersion: 2, + }) + + expect(result.failure?.message).toMatch(/eqlVersion 2|eql_v3_\* domains/) + expect(newClientCalls()).toHaveLength(1) + }) + + it('returns the TYPED client, not the bare nominal one', async () => { + const client = await Encryption({ schemas: [users] }) + + const result = await (client as unknown as EncryptionClient).init({ + encryptConfig: buildEncryptConfig(users), + }) + + // `EncryptionClient.init` resolves `{ data: this }` — the nominal client. + // Reassigning from it (`client = (await client.init(c)).data`) is the + // natural idiom, and it must not silently drop the typed surface. + if (result.failure) throw new Error(result.failure.message) + expect( + typeof (result.data as { encryptQuery?: unknown }).encryptQuery, + ).toBe('function') + expect(result.data).toBe(client) + }) +}) diff --git a/packages/stack/__tests__/typed-client-nominal-parity.test.ts b/packages/stack/__tests__/typed-client-nominal-parity.test.ts new file mode 100644 index 000000000..dc0bcc1c2 --- /dev/null +++ b/packages/stack/__tests__/typed-client-nominal-parity.test.ts @@ -0,0 +1,82 @@ +/** + * Runtime parity between the two clients `Encryption` can return. + * + * `Encryption` decides its return value with two discriminators that can + * disagree: overload resolution reads the `config` argument, while the runtime + * reads the `schemas` argument (`encryption/index.ts`, the `isV3Only && + * eqlVersion === 3` branch). Hoisting a config into a `ClientConfig`-typed + * variable is enough to split them — `ClientConfig.eqlVersion` is `2 | 3`, which + * is not assignable to the v3 overload's `eqlVersion?: 3`, so the call selects + * the NOMINAL overload while the runtime still hands back the TYPED client. + * + * No type-level design closes that: the runtime inspects values, the type + * inspects a static type that can be widened away. It ends when the EQL v2 + * removal collapses the two clients into one (#637). Until then the mismatch + * must not be able to crash, which means every member of `EncryptionClient` has + * to exist on the typed client at runtime. + */ +import { describe, expect, it, vi } from 'vitest' +import { Encryption, EncryptionClient } from '@/encryption' +import { encryptedTable, types } from '@/encryption/v3' +import type { ClientConfig } from '@/types' + +const users = encryptedTable('users', { email: types.TextSearch('email') }) + +/** + * Build a client without credentials by stubbing `init` to resolve to itself — + * `Encryption` only needs a successful `Result` to reach its return branch. + */ +async function buildWithStubbedInit(config: ClientConfig) { + const spy = vi + .spyOn(EncryptionClient.prototype, 'init') + .mockImplementation(async function (this: EncryptionClient) { + return { data: this } + } as never) + try { + return await Encryption({ schemas: [users], config }) + } finally { + spy.mockRestore() + } +} + +describe('typed client / nominal client runtime parity', () => { + it('a ClientConfig-typed variable types as nominal but returns the typed client', async () => { + const config: ClientConfig = {} + const client = await buildWithStubbedInit(config) + + // The static type here is `EncryptionClient`. The runtime disagrees. + expect('encryptQuery' in client).toBe(true) + expect(client).not.toBeInstanceOf(EncryptionClient) + }) + + it('exposes every EncryptionClient member, so the mismatch cannot crash', async () => { + const config: ClientConfig = {} + const client = await buildWithStubbedInit(config) + + const nominalMembers = Object.getOwnPropertyNames( + EncryptionClient.prototype, + ).filter((name) => name !== 'constructor') + + // `init` was the only member missing, which turned the type/runtime + // mismatch above into `TypeError: client.init is not a function` for + // anything holding the client through its declared `EncryptionClient` type. + const missing = nominalMembers.filter( + (name) => typeof (client as Record<string, unknown>)[name] !== 'function', + ) + expect(missing).toEqual([]) + }) + + it('delegates init to the underlying client', async () => { + const config: ClientConfig = {} + const client = await buildWithStubbedInit(config) + + const spy = vi + .spyOn(EncryptionClient.prototype, 'init') + .mockResolvedValue({ data: {} } as never) + + await (client as unknown as EncryptionClient).init({} as never) + expect(spy).toHaveBeenCalledTimes(1) + + spy.mockRestore() + }) +}) diff --git a/packages/stack/__tests__/typed-client-v3.test-d.ts b/packages/stack/__tests__/typed-client-v3.test-d.ts index 03b929c9b..58419bd1b 100644 --- a/packages/stack/__tests__/typed-client-v3.test-d.ts +++ b/packages/stack/__tests__/typed-client-v3.test-d.ts @@ -146,6 +146,25 @@ describe('typed v3 client — model decrypt yields precise plaintext', () => { users, ) }) + + it('decryptModel / bulkDecryptModels are chainable with .audit() and .withLockContext()', () => { + // The typed client's decrypt methods return a chainable operation (not a bare + // Promise), so audit metadata and lock context can be attached before await. + const op = client.decryptModel({ id: 'u1', email: {} as Encrypted }, users) + expectTypeOf(op).toHaveProperty('audit') + expectTypeOf(op).toHaveProperty('withLockContext') + // Both stay chainable — same operation type back. + expectTypeOf(op.audit({ metadata: { sub: 'u1' } })).toEqualTypeOf< + typeof op + >() + + const bulkOp = client.bulkDecryptModels( + [{ id: 'u1', email: {} as Encrypted }], + users, + ) + expectTypeOf(bulkOp).toHaveProperty('audit') + expectTypeOf(bulkOp).toHaveProperty('withLockContext') + }) }) describe('typed v3 client — soundness', () => { diff --git a/packages/stack/__tests__/typed-client-v3.test.ts b/packages/stack/__tests__/typed-client-v3.test.ts index 5969989b7..c506d787f 100644 --- a/packages/stack/__tests__/typed-client-v3.test.ts +++ b/packages/stack/__tests__/typed-client-v3.test.ts @@ -11,14 +11,32 @@ const table = encryptedTable('t', { }) /** - * A minimal client stub whose model-decrypt methods resolve to a fixed - * `Result` payload. `typedClient` only `await`s these, so a plain Promise is a - * sufficient thenable. + * A minimal operation stub resolving to a fixed `Result`. `typedClient` now + * wraps the underlying decrypt op in a `MappedDecryptOperation` and calls + * `.execute()` on it (rather than awaiting a bare promise), so the stub must be + * operation-like: `.execute()` plus the chainable `.audit()` / `.withLockContext()` + * the wrapper may delegate to. + */ +function fakeOp<R>(result: R) { + return { + execute: () => Promise.resolve(result), + audit() { + return this + }, + withLockContext() { + return this + }, + } +} + +/** + * A minimal client stub whose model-decrypt methods return an operation + * resolving to a fixed `Result` payload. */ function fakeClient(data: Record<string, unknown>): EncryptionClient { return { - decryptModel: () => Promise.resolve({ data }), - bulkDecryptModels: () => Promise.resolve({ data: [data] }), + decryptModel: () => fakeOp({ data }), + bulkDecryptModels: () => fakeOp({ data: [data] }), } as unknown as EncryptionClient } @@ -77,7 +95,7 @@ describe('typedClient — decrypt reconstruction', () => { it('propagates a failure result unchanged', async () => { const failing = { decryptModel: () => - Promise.resolve({ + fakeOp({ failure: { type: 'DecryptionError', message: 'boom' }, }), } as unknown as EncryptionClient @@ -122,4 +140,52 @@ describe('typedClient — decrypt reconstruction', () => { const data = result.data as Record<string, unknown> expect(data.when).toBeInstanceOf(Date) }) + + // `Encryption` now returns THIS typed client for a v3 schema set, so a consumer + // typed against the nominal overload (e.g. stack-supabase's query builder, + // which casts to it and calls the one-arg `decryptModel(row)` / + // `bulkDecryptModels(rows)`) reaches the typed methods with NO table argument. + // They must decrypt without throwing — degrading to nominal behaviour (no date + // reconstruction) — not dereference `undefined.tableName`. + it('tolerates a one-arg (nominal-style) decryptModel call with no table', async () => { + const client = typedClient( + fakeClient({ when: '2020-01-02T03:04:05.000Z', note: 'hi' }), + table, + ) + // The typed signature forbids the one-arg form; a nominal-typed caller does + // it at runtime. Exercise that runtime path. + // biome-ignore lint/suspicious/noExplicitAny: exercising the nominal-arity runtime path + const decryptOneArg = client.decryptModel as any + + const result = await decryptOneArg({ + when: '2020-01-02T03:04:05.000Z', + note: 'hi', + }) + expect(result.failure).toBeFalsy() + if (result.failure) return + + const data = result.data as Record<string, unknown> + // No table → no reconstruction: `when` stays the raw string, exactly as the + // nominal client would return it. + expect(data.when).toBe('2020-01-02T03:04:05.000Z') + expect(data.note).toBe('hi') + }) + + it('tolerates a one-arg (nominal-style) bulkDecryptModels call with no table', async () => { + const client = typedClient( + fakeClient({ when: '2021-06-01T00:00:00.000Z', note: 'x' }), + table, + ) + // biome-ignore lint/suspicious/noExplicitAny: exercising the nominal-arity runtime path + const bulkOneArg = client.bulkDecryptModels as any + + const result = await bulkOneArg([{ when: '2021-06-01T00:00:00.000Z' }]) + expect(result.failure).toBeFalsy() + if (result.failure) return + + const rows = result.data as Array<Record<string, unknown>> + expect(rows).toHaveLength(1) + // No table → no reconstruction: raw string, not a Date. + expect(rows[0].when).toBe('2021-06-01T00:00:00.000Z') + }) }) diff --git a/packages/stack/__tests__/v3-matrix/matrix-audit.test-d.ts b/packages/stack/__tests__/v3-matrix/matrix-audit.test-d.ts index cdaa95db9..e3b56d954 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-audit.test-d.ts +++ b/packages/stack/__tests__/v3-matrix/matrix-audit.test-d.ts @@ -1,9 +1,10 @@ /** - * Type-level pin of a real v3 asymmetry: audit metadata is available on the - * encrypt-side operations (which are chainable) but NOT on `decryptModel` / - * `bulkDecryptModels`, which return a bare `Promise<Result<…>>` rather than a - * chainable operation. Documented here as an executable invariant so the gap - * (v2's `decryptModel().audit(...)` has no v3 equivalent) can't silently change. + * Type-level pin that audit + lock-context are chainable on BOTH the encrypt-side + * operations AND `decryptModel` / `bulkDecryptModels`. The typed client's decrypt + * methods now return a chainable {@link MappedDecryptOperation} (was a bare + * `Promise<Result<…>>`), restoring the v2-era `decryptModel().audit(...)` surface + * on the v3 client. Documented here as an executable invariant so the capability + * can't silently regress. * * Runs via `pnpm test:types`. */ @@ -22,10 +23,16 @@ describe('v3 typed client audit/lock-context chainability (types)', () => { expectTypeOf(op).toHaveProperty('withLockContext') }) - it('does NOT expose .audit()/.withLockContext() on decryptModel (bare Promise)', () => { - const result = typed.decryptModel({ email: {} as never }, users) - // A Promise, not a chainable operation — no audit/lock-context hook. - expectTypeOf(result).not.toHaveProperty('audit') - expectTypeOf(result).not.toHaveProperty('withLockContext') + it('exposes .audit()/.withLockContext() on decryptModel (chainable operation)', () => { + const op = typed.decryptModel({ email: {} as never }, users) + // A chainable operation, not a bare Promise — audit + lock-context hooks. + expectTypeOf(op).toHaveProperty('audit') + expectTypeOf(op).toHaveProperty('withLockContext') + }) + + it('exposes .audit()/.withLockContext() on bulkDecryptModels (chainable operation)', () => { + const op = typed.bulkDecryptModels([{ email: {} as never }], users) + expectTypeOf(op).toHaveProperty('audit') + expectTypeOf(op).toHaveProperty('withLockContext') }) }) diff --git a/packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts b/packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts index d8041e338..0024902d6 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts +++ b/packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts @@ -37,7 +37,8 @@ vi.mock('@cipherstash/protect-ffi', () => ({ })) import * as ffi from '@cipherstash/protect-ffi' -import { encryptedTable, typedClient, types } from '@/encryption/v3' +import type { EncryptionClientFor } from '@/encryption/v3' +import { encryptedTable, types } from '@/encryption/v3' const users = encryptedTable('users', { email: types.TextEq('email'), @@ -68,14 +69,16 @@ function unwrap(result: any) { // biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args const lastOpts = (fn: any) => fn.mock.calls.at(-1)[1] -let typed: ReturnType<typeof typedClient> +// `Encryption` returns the typed client directly for an all-v3 schema set (the +// collapse of `EncryptionV3`), so there is no separate `typedClient` wrap here. +let typed: EncryptionClientFor<readonly [typeof users]> let prevWorkspaceCrn: string | undefined beforeEach(async () => { vi.clearAllMocks() prevWorkspaceCrn = process.env.CS_WORKSPACE_CRN process.env.CS_WORKSPACE_CRN = 'crn:ap-southeast-2.aws:test-workspace' - typed = typedClient(await Encryption({ schemas: [users] as never }), users) + typed = await Encryption({ schemas: [users] }) }) afterEach(() => { diff --git a/packages/stack/dist-types/README.md b/packages/stack/dist-types/README.md new file mode 100644 index 000000000..69e727c9c --- /dev/null +++ b/packages/stack/dist-types/README.md @@ -0,0 +1,15 @@ +# Declaration-emit contract + +These files typecheck against `../dist`, not `../src`. + +Every other type test in this package imports `@/…`, which resolves to source. +That is the right default — it keeps the tests fast and independent of a build — +but it means nothing checked what customers actually consume, and a defect lived +in the published `.d.ts` for the whole rc series because of it: +`EncryptedV3Column`'s domain parameter `D` was recoverable in source and not in +the emitted declarations, so typed `encryptQuery` was uncallable for every column +against the published package. + +Anything whose correctness depends on what `tsc` *emits* — rather than on what +the source says — belongs here. Requires a build first; wired into CI as +`test:types:dist`. diff --git a/packages/stack/dist-types/encrypt-query.ts b/packages/stack/dist-types/encrypt-query.ts new file mode 100644 index 000000000..a8b0ac83d --- /dev/null +++ b/packages/stack/dist-types/encrypt-query.ts @@ -0,0 +1,69 @@ +/** + * Typed `encryptQuery` must be callable against the BUILT package. + * + * `PlaintextForColumn` / `QueryTypesForColumn` recover a column's domain with + * `C extends EncryptedV3Column<infer D>`, which needs `D` to appear bare in the + * instance type. Its only bare site in source is a PRIVATE field, and `tsc` + * strips private member types on declaration emit — so in the published `.d.ts` + * the inference fell back to the `V3DomainDefinition` constraint and every + * derived helper collapsed. `QueryTypesForColumn` became `never`, which made + * `QueryableColumnsOf` `never`, which typed the plaintext `never`: + * + * client.encryptQuery('a@b.com', { table: users, column: users.email }) + * // TS2345: Argument of type '"a@b.com"' is not assignable to type 'never' + * + * `encrypt` was unaffected (it resolves through `ColumnsOf`), so a smoke test + * of the built types would have missed it. Assert the query path explicitly. + */ + +import { Encryption, encryptedTable, types } from '../dist/encryption/v3.js' +import type { + EncryptedTextSearchColumn, + PlaintextForColumn, + QueryTypesForColumn, +} from '../dist/eql/v3/index.js' + +// The two helpers, on the concrete column class, straight from the emitted types. +const plaintext: string = + null as unknown as PlaintextForColumn<EncryptedTextSearchColumn> +const queryTypes: 'equality' | 'orderAndRange' | 'freeTextSearch' = + null as unknown as QueryTypesForColumn<EncryptedTextSearchColumn> +void plaintext +void queryTypes + +const users = encryptedTable('users', { + email: types.TextSearch('email'), + age: types.IntegerOrd('age'), +}) + +export async function callable() { + const client = await Encryption({ schemas: [users] }) + + client.encrypt('a@b.com', { table: users, column: users.email }) + client.encryptQuery('a@b.com', { + table: users, + column: users.email, + queryType: 'freeTextSearch', + }) + client.encryptQuery(30, { + table: users, + column: users.age, + queryType: 'orderAndRange', + }) + + // The narrowing must survive emit too, or the assertions above pass + // vacuously. Each directive sits on the exact line the error is reported at — + // `@ts-expect-error` only covers the following line, and the formatter is free + // to split these calls across several. + client.encryptQuery( + // @ts-expect-error - `email` is a text domain, so the plaintext is a string + 123, + { table: users, column: users.email }, + ) + client.encryptQuery('x', { + table: users, + column: users.email, + // @ts-expect-error - `searchableJson` is not a capability of text_search + queryType: 'searchableJson', + }) +} diff --git a/packages/stack/dist-types/node16/encrypt-query.cts b/packages/stack/dist-types/node16/encrypt-query.cts new file mode 100644 index 000000000..32b48e7b1 --- /dev/null +++ b/packages/stack/dist-types/node16/encrypt-query.cts @@ -0,0 +1,67 @@ +/** + * The CommonJS declaration surface — `require` → `dist/**\/*.d.cts`. + * + * The sibling `../encrypt-query.ts` gate reaches into `../dist/*.js` by relative + * path under `moduleResolution: bundler`, so it never consults the `exports` + * map and only ever sees the ESM `.d.ts` set. tsup emits the `.d.cts` set in a + * separate pass, and `package.json` routes `require` at it. A defect confined to + * that pass would ship to every CJS consumer with nothing here to catch it. + * + * This file is `.cts`, so TypeScript treats it as CommonJS and resolves + * `@cipherstash/stack/v3` through the `require` condition. Importing BY PACKAGE + * NAME (Node self-reference, which works because `package.json` has both a + * `name` and an `exports` map) is the point: it exercises the conditions a + * customer's resolver walks, not a path we happen to know. + */ + +import type { + EncryptedTextSearchColumn, + PlaintextForColumn, + QueryTypesForColumn, +} from '@cipherstash/stack/eql/v3' +import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3' + +// The two helpers, on the concrete column class, straight from the emitted +// `.d.cts`. These collapse to `never` / a wide union if the domain parameter is +// not recoverable after declaration emit. +const plaintext: string = + null as unknown as PlaintextForColumn<EncryptedTextSearchColumn> +const queryTypes: 'equality' | 'orderAndRange' | 'freeTextSearch' = + null as unknown as QueryTypesForColumn<EncryptedTextSearchColumn> +void plaintext +void queryTypes + +const users = encryptedTable('users', { + email: types.TextSearch('email'), + age: types.IntegerOrd('age'), +}) + +export async function callable() { + const client = await Encryption({ schemas: [users] }) + + client.encrypt('a@b.com', { table: users, column: users.email }) + client.encryptQuery('a@b.com', { + table: users, + column: users.email, + queryType: 'freeTextSearch', + }) + client.encryptQuery(30, { + table: users, + column: users.age, + queryType: 'orderAndRange', + }) + + // The narrowing must survive emit too, or the assertions above pass + // vacuously. Each directive sits on the exact line the error is reported at. + client.encryptQuery( + // @ts-expect-error - `email` is a text domain, so the plaintext is a string + 123, + { table: users, column: users.email }, + ) + client.encryptQuery('x', { + table: users, + column: users.email, + // @ts-expect-error - `searchableJson` is not a capability of text_search + queryType: 'searchableJson', + }) +} diff --git a/packages/stack/dist-types/node16/encrypt-query.mts b/packages/stack/dist-types/node16/encrypt-query.mts new file mode 100644 index 000000000..ba8ac246f --- /dev/null +++ b/packages/stack/dist-types/node16/encrypt-query.mts @@ -0,0 +1,66 @@ +/** + * The ESM declaration surface under NODE16 resolution — `import` → `*.d.ts`. + * + * `../encrypt-query.ts` already covers the `.d.ts` set, but through a relative + * path under `moduleResolution: bundler`. That combination cannot catch a broken + * `exports` map, a missing `types` condition, or a subpath that resolves under a + * bundler but not under Node's own algorithm — the resolution mode most server + * consumers actually use. + * + * Same assertions as the `.cts` twin; the only difference is which condition the + * resolver takes, which is exactly what is under test. + */ + +import type { + EncryptedTextSearchColumn, + PlaintextForColumn, + QueryTypesForColumn, +} from '@cipherstash/stack/eql/v3' +import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3' + +const plaintext: string = + null as unknown as PlaintextForColumn<EncryptedTextSearchColumn> +const queryTypes: 'equality' | 'orderAndRange' | 'freeTextSearch' = + null as unknown as QueryTypesForColumn<EncryptedTextSearchColumn> +void plaintext +void queryTypes + +const users = encryptedTable('users', { + email: types.TextSearch('email'), + age: types.IntegerOrd('age'), +}) + +// The domain carrier that makes the inference above possible must not be part +// of the public surface. There is no `stripInternal` in this build, so a plainly +// named property would be reachable here — typed `D | undefined`, and always +// `undefined` at runtime. Keyed by a non-exported `unique symbol`, it is not. +// @ts-expect-error - the phantom domain carrier is not nameable by consumers +void users.email.__domain + +export async function callable() { + const client = await Encryption({ schemas: [users] }) + + client.encrypt('a@b.com', { table: users, column: users.email }) + client.encryptQuery('a@b.com', { + table: users, + column: users.email, + queryType: 'freeTextSearch', + }) + client.encryptQuery(30, { + table: users, + column: users.age, + queryType: 'orderAndRange', + }) + + client.encryptQuery( + // @ts-expect-error - `email` is a text domain, so the plaintext is a string + 123, + { table: users, column: users.email }, + ) + client.encryptQuery('x', { + table: users, + column: users.email, + // @ts-expect-error - `searchableJson` is not a capability of text_search + queryType: 'searchableJson', + }) +} diff --git a/packages/stack/dist-types/node16/tsconfig.json b/packages/stack/dist-types/node16/tsconfig.json new file mode 100644 index 000000000..034b0ae3f --- /dev/null +++ b/packages/stack/dist-types/node16/tsconfig.json @@ -0,0 +1,26 @@ +{ + // The sibling gate resolves `../dist/*.js` by RELATIVE path under + // `moduleResolution: bundler`, which never consults the `exports` map. This + // one resolves `@cipherstash/stack/*` BY PACKAGE NAME under `node16` — the + // way a customer does — so the conditions in `exports` select the artifact. + // + // The package is `"type": "module"`, so the file extension picks the mode: + // `.cts` is CommonJS and takes the `require` branch (`*.d.cts`), `.mts` is + // ESM and takes the `import` branch (`*.d.ts`). Both must be probed: tsup + // emits the two declaration sets in separate passes, and a third for + // `wasm-inline`. + // + // No `customConditions` here: `node` is implicit under node16, and this + // package's own `exports` map does not branch on it. + "compilerOptions": { + "lib": ["ES2022", "DOM"], + "target": "ES2022", + "module": "node16", + "moduleResolution": "node16", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": [] + }, + "include": ["**/*.cts", "**/*.mts"] +} diff --git a/packages/stack/dist-types/node16/wasm-inline.mts b/packages/stack/dist-types/node16/wasm-inline.mts new file mode 100644 index 000000000..947e2b4ca --- /dev/null +++ b/packages/stack/dist-types/node16/wasm-inline.mts @@ -0,0 +1,33 @@ +/** + * The THIRD declaration artifact: `dist/wasm-inline.d.ts`. + * + * `tsup.config.ts` runs a second, independent DTS pass for the wasm-inline + * entry, which inlines its own copy of `EncryptedV3Column` and the helpers that + * invert its domain parameter. Neither the bundler gate nor the `.cts`/`.mts` + * probes above reach it — they resolve `./v3` and `./eql/v3`, which come from + * the first pass. So the entry documented for Workers, Deno, Bun and Supabase + * Edge — the runtimes with the least margin for a broken type — was the one + * artifact nothing typechecked. + * + * `./wasm-inline` is ESM-only in the `exports` map (no `require` branch, by + * design: the inlined WASM blob cannot be `require`d), hence `.mts` and no + * `.cts` twin. + * + * `encryptQuery` is deliberately NOT column-narrowed on this entry + * (`WasmEncryptQueryOptions.column` is wide), so this asserts the helpers + * directly rather than through a call — they are what carries the domain, and + * what collapses if the phantom carrier is lost on emit. + */ + +import type { + EncryptedTextSearchColumn, + PlaintextForColumn, + QueryTypesForColumn, +} from '@cipherstash/stack/wasm-inline' + +const plaintext: string = + null as unknown as PlaintextForColumn<EncryptedTextSearchColumn> +const queryTypes: 'equality' | 'orderAndRange' | 'freeTextSearch' = + null as unknown as QueryTypesForColumn<EncryptedTextSearchColumn> +void plaintext +void queryTypes diff --git a/packages/stack/dist-types/tsconfig.json b/packages/stack/dist-types/tsconfig.json new file mode 100644 index 000000000..4e52e3b05 --- /dev/null +++ b/packages/stack/dist-types/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "lib": ["ES2022", "DOM"], + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "customConditions": ["node"], + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + // `node16/` is the same contract checked under Node's own resolution, by + // package name rather than relative path. It needs `module: node16`, so it + // carries its own tsconfig and must not be swept up by this one. + "exclude": ["node16"], + "include": ["**/*.ts"] +} diff --git a/packages/stack/integration/identity/matrix-identity.integration.test.ts b/packages/stack/integration/identity/matrix-identity.integration.test.ts index 5009a4a74..6f4bfa709 100644 --- a/packages/stack/integration/identity/matrix-identity.integration.test.ts +++ b/packages/stack/integration/identity/matrix-identity.integration.test.ts @@ -18,6 +18,7 @@ import { OidcFederationStrategy } from '@cipherstash/auth' import { unwrapResult } from '@cipherstash/test-kit' import { clerkJwtProvider } from '@cipherstash/test-kit/integration-clerk' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClientFor } from '@/encryption/v3' import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' const users = encryptedTable('v3_identity_live_users', { @@ -35,7 +36,7 @@ const INFRA_FAULT = /ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up|timed? ?out|network error/i describe('v3 typed client identity-aware operations (live)', () => { - let client: Awaited<ReturnType<typeof EncryptionV3<[typeof users]>>> + let client: EncryptionClientFor<readonly [typeof users]> beforeAll(async () => { const crn = process.env.CS_WORKSPACE_CRN @@ -114,7 +115,7 @@ describe('v3 typed client identity-aware operations (live)', () => { // test on a Clerk outage or a rotated `CLERK_MACHINE_TOKEN` — for behaviour it // never exercises. describe('v3 typed client audit metadata (live)', () => { - let client: Awaited<ReturnType<typeof EncryptionV3<[typeof users]>>> + let client: EncryptionClientFor<readonly [typeof users]> beforeAll(async () => { client = await EncryptionV3({ schemas: [users] }) diff --git a/packages/stack/integration/shared/json-crypto.integration.test.ts b/packages/stack/integration/shared/json-crypto.integration.test.ts index dd66d412b..0a54d708a 100644 --- a/packages/stack/integration/shared/json-crypto.integration.test.ts +++ b/packages/stack/integration/shared/json-crypto.integration.test.ts @@ -7,6 +7,7 @@ */ import { unwrapResult } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClientFor } from '@/encryption/v3' import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' const docs = encryptedTable('v3_json_docs', { @@ -14,7 +15,7 @@ const docs = encryptedTable('v3_json_docs', { }) describe('v3 typed client — encrypted JSONB round-trip', () => { - let client: Awaited<ReturnType<typeof EncryptionV3<[typeof docs]>>> + let client: EncryptionClientFor<readonly [typeof docs]> beforeAll(async () => { client = await EncryptionV3({ schemas: [docs] }) diff --git a/packages/stack/integration/shared/match-bloom.integration.test.ts b/packages/stack/integration/shared/match-bloom.integration.test.ts index 95fb70115..7b2543627 100644 --- a/packages/stack/integration/shared/match-bloom.integration.test.ts +++ b/packages/stack/integration/shared/match-bloom.integration.test.ts @@ -1,5 +1,7 @@ import { expect, it } from 'vitest' +import type { EncryptionClientFor } from '@/encryption/v3' import { EncryptionV3 } from '@/encryption/v3' +import type { AnyV3Table } from '@/eql/v3' import { encryptedTable, types } from '@/eql/v3' /** @@ -36,7 +38,7 @@ const docs = encryptedTable('match_bloom_probe', { const HAYSTACK = 'ada@example.com' async function bloomOf( - client: Awaited<ReturnType<typeof EncryptionV3>>, + client: EncryptionClientFor<readonly AnyV3Table[]>, value: string, ) { const result = await client.encrypt(value, { column: docs.bio, table: docs }) diff --git a/packages/stack/integration/shared/matrix-bulk.integration.test.ts b/packages/stack/integration/shared/matrix-bulk.integration.test.ts index 0ef71beea..e9123ae54 100644 --- a/packages/stack/integration/shared/matrix-bulk.integration.test.ts +++ b/packages/stack/integration/shared/matrix-bulk.integration.test.ts @@ -7,6 +7,7 @@ */ import { unwrapResult } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClientFor } from '@/encryption/v3' import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' const people = encryptedTable('v3_bulk_people', { @@ -15,7 +16,7 @@ const people = encryptedTable('v3_bulk_people', { }) describe('v3 typed client bulk-at-scale (live)', () => { - let client: Awaited<ReturnType<typeof EncryptionV3<[typeof people]>>> + let client: EncryptionClientFor<readonly [typeof people]> beforeAll(async () => { client = await EncryptionV3({ schemas: [people] }) diff --git a/packages/stack/integration/shared/matrix-crypto.integration.test.ts b/packages/stack/integration/shared/matrix-crypto.integration.test.ts index 03d9fcd0d..8358d4c2d 100644 --- a/packages/stack/integration/shared/matrix-crypto.integration.test.ts +++ b/packages/stack/integration/shared/matrix-crypto.integration.test.ts @@ -26,7 +26,12 @@ import { V3_MATRIX, } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' -import { EncryptionV3, encryptedTable } from '@/encryption/v3' +import { + type EncryptionClientFor, + EncryptionV3, + encryptedTable, +} from '@/encryption/v3' +import type { AnyV3Table } from '@/eql/v3' // `as const satisfies Record<...>` gives `V3_MATRIX` a narrower type than // `Record<EqlV3TypeName, DomainSpec>` (rows that omit the optional @@ -70,7 +75,7 @@ const errorCases = domains.flatMap(([t, spec]) => ) describe('v3 matrix live round-trip (all domains × samples)', () => { - let client: Awaited<ReturnType<typeof EncryptionV3>> + let client: EncryptionClientFor<readonly AnyV3Table[]> let encrypted: Array<Record<string, unknown>> let decrypted: Array<Record<string, unknown>> diff --git a/packages/stack/integration/shared/matrix-sql.integration.test.ts b/packages/stack/integration/shared/matrix-sql.integration.test.ts index 3532cdcbc..2fb113887 100644 --- a/packages/stack/integration/shared/matrix-sql.integration.test.ts +++ b/packages/stack/integration/shared/matrix-sql.integration.test.ts @@ -67,7 +67,12 @@ import { } from '@cipherstash/test-kit' import postgres from 'postgres' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { EncryptionV3, encryptedTable } from '@/encryption/v3' +import { + type EncryptionClientFor, + EncryptionV3, + encryptedTable, +} from '@/encryption/v3' +import type { AnyV3Table } from '@/eql/v3' // Previously force-skipped (CI run 28569708268, PR #540): `beforeAll` crashed // with `PostgresError: invalid input syntax for type json` on the dynamic @@ -203,7 +208,7 @@ const comparePlaintext = (a: unknown, b: unknown): number => { type Row = { id: number } -let client: Awaited<ReturnType<typeof EncryptionV3>> +let client: EncryptionClientFor<readonly AnyV3Table[]> let idA: number let idB: number // Separate run id for the multi-row ordering rows. Kept DISTINCT from @@ -399,7 +404,9 @@ describe('v3 matrix live Postgres coverage (all covered domains)', () => { const col = slug(eqlType) const column = (table as unknown as Record<string, unknown>)[col] as never const encrypted = unwrapResult( - await client.encrypt('', { + // The matrix table is built from a dynamic column map, so `column` is + // already erased above and the plaintext cannot be derived from it. + await client.encrypt('' as never, { table: table as never, column, }), @@ -416,7 +423,9 @@ describe('v3 matrix live Postgres coverage (all covered domains)', () => { const col = slug(eqlType) const column = (table as unknown as Record<string, unknown>)[col] as never const encrypted = unwrapResult( - await client.encrypt('', { + // The matrix table is built from a dynamic column map, so `column` is + // already erased above and the plaintext cannot be derived from it. + await client.encrypt('' as never, { table: table as never, column, }), diff --git a/packages/stack/integration/shared/schema-pg.integration.test.ts b/packages/stack/integration/shared/schema-pg.integration.test.ts index a8c8992de..152b94801 100644 --- a/packages/stack/integration/shared/schema-pg.integration.test.ts +++ b/packages/stack/integration/shared/schema-pg.integration.test.ts @@ -1,7 +1,7 @@ import { databaseUrl, unwrapResult } from '@cipherstash/test-kit' import postgres from 'postgres' import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' -import type { EncryptionClient } from '@/encryption' +import type { EncryptionClientFor } from '@/encryption/v3' import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' import type { Encrypted } from '@/types' @@ -28,7 +28,9 @@ type InsertedRow = { type EncryptionPayload = postgres.JSONValue -let protectClient: EncryptionClient +let protectClient: EncryptionClientFor< + readonly [typeof table, typeof typedTable] +> async function encryptValue(value: string): Promise<EncryptionPayload> { return unwrapResult( @@ -48,7 +50,7 @@ async function encryptValue(value: string): Promise<EncryptionPayload> { // term, so which SQL function you call selects which term is compared. async function encryptOperand( value: unknown, - opts: Parameters<EncryptionClient['encrypt']>[1], + opts: Parameters<typeof protectClient.encrypt>[1], ): Promise<EncryptionPayload> { return unwrapResult( await protectClient.encrypt(value as never, opts), diff --git a/packages/stack/integration/shared/schema-v3-client.integration.test.ts b/packages/stack/integration/shared/schema-v3-client.integration.test.ts index 839ded21a..80e60e647 100644 --- a/packages/stack/integration/shared/schema-v3-client.integration.test.ts +++ b/packages/stack/integration/shared/schema-v3-client.integration.test.ts @@ -1,7 +1,6 @@ import { unwrapResult } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' -import type { EncryptionClient } from '@/encryption' -import { typedClient } from '@/encryption/v3' +import type { EncryptionClientFor } from '@/encryption/v3' import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' @@ -20,7 +19,7 @@ const users = encryptedTable('schema_v3_client_users', { }) describe('eql_v3 client integration', () => { - let protectClient: EncryptionClient + let protectClient: EncryptionClientFor<readonly [typeof users]> beforeAll(async () => { protectClient = await Encryption({ schemas: [users] }) @@ -173,7 +172,9 @@ describe('eql_v3 client integration', () => { // `Date` from the encrypt-config `cast_as` (`reconstructRow`), keyed by the // JS property (`createdOn`) even though the DB column is `created_on`. it('round-trips a representative date storage domain via decryptModel', async () => { - const typed = typedClient(protectClient, users) + // `Encryption` returns the typed client for an all-v3 schema set, so + // `protectClient` already carries the typed model methods — no extra wrap. + const typed = protectClient // Zero milliseconds so the FFI dropping sub-second precision (`...00Z` vs // `...000Z`) does not perturb the reconstructed instant. const day = new Date('2026-07-01T00:00:00.000Z') @@ -203,7 +204,9 @@ describe('eql_v3 client integration', () => { // ENCRYPTED by the model path — not silently passed through as plaintext // because the field key (`createdOn`) fails to match the DB-keyed config. it('encrypts a property-vs-DB-name column through encryptModel (no plaintext leak)', async () => { - const typed = typedClient(protectClient, users) + // `Encryption` returns the typed client for an all-v3 schema set, so + // `protectClient` already carries the typed model methods — no extra wrap. + const typed = protectClient const day = new Date('2026-07-01T00:00:00.000Z') const encrypted = unwrapResult( @@ -232,7 +235,9 @@ describe('eql_v3 client integration', () => { // ms-zeroed `12:34:56` value must round-trip exactly. (Was skipped while every // timestamp domain wrongly set `cast_as: 'date'`; re-enabled with that fix.) it('round-trips a timestamp occurredAt column through the model path', async () => { - const typed = typedClient(protectClient, users) + // `Encryption` returns the typed client for an all-v3 schema set, so + // `protectClient` already carries the typed model methods — no extra wrap. + const typed = protectClient // Zero milliseconds: the FFI drops sub-second precision, so a ms-bearing // instant would perturb the reconstructed value. const moment = new Date('2026-07-01T12:34:56.000Z') diff --git a/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts b/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts new file mode 100644 index 000000000..7926837a7 --- /dev/null +++ b/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts @@ -0,0 +1,213 @@ +/** + * Acceptance #1a + #1b + #1c — EQL v2 READ compatibility after the v3 collapse. + * + * The client authors EQL v3 only, but MUST keep reading previously stored EQL v2 + * payloads — on the core client (guardrail 1) AND through the DynamoDB adapter + * (guardrail 2). This suite mints v2 data with a v2-mode `Encryption` client (the + * retained `config: { eqlVersion: 2 }` escape hatch) and proves it still + * round-trips: + * + * - #1a: a v2 ciphertext + a v2 model decrypt through the collapsed `Encryption` + * client's `decrypt` / `decryptModel`. + * - #1b: a stored v2 DynamoDB item — split with the exported + * `toEncryptedDynamoItem(payload, attrs, false)` — decrypts through + * `encryptedDynamoDB(...).decryptModel(item, v2Table)`, exercising the v2 + * envelope reconstruction (`toItemWithEqlPayloads`, `v === 2` / `k: 'ct'`). + * - #1c: the SAME v2 payloads decrypt through a **v3-configured** client that + * has never heard of the v2 table. + * + * #1c is the invariant customers actually depend on and the reason this file + * cannot be a v2-only suite: after the removal their client is v3, their stored + * data is v2, and #1a/#1b would keep passing even if a v3 client had lost the + * ability to read v2 — because both mint AND read through the v2-mode client. + * Whatever eventually deletes the `eqlVersion: 2` minting path must keep #1c + * alive (against a checked-in ciphertext fixture, or a raw `EncryptConfig`), + * not delete it along with the escape hatch. + * + * Live: requires real ZeroKMS credentials (the integration harness provisions + * them and throws otherwise). The credential-free half of the v2 read path — the + * DynamoDB envelope split/reconstruction over static payloads — is covered by + * `__tests__/dynamodb/helpers.test.ts`, which needs no network at all. + */ +import { unwrapResult } from '@cipherstash/test-kit' +import { beforeAll, describe, expect, it } from 'vitest' +import { encryptedDynamoDB } from '@/dynamodb' +import { toEncryptedDynamoItem } from '@/dynamodb/helpers' +import type { EncryptionClient } from '@/encryption' +import { encryptedTable as encryptedTableV3, types as typesV3 } from '@/eql/v3' +import { Encryption } from '@/index' +// The deprecated v2 authoring builders remain for reading/migrating legacy data. +import { encryptedColumn, encryptedTable } from '@/schema' + +// A minimal EQL v2 table (v2 builders → no `buildColumnKeyMap`, so `Encryption` +// returns the nominal client, not the typed one). +const usersV2 = encryptedTable('v2_read_compat_users', { + email: encryptedColumn('email').equality(), +}) + +// A DIFFERENT table, in v3 builders, so the v3 client below carries no knowledge +// of the v2 table whatsoever — reading v2 data must not depend on the v2 schema +// still being registered. +const unrelatedV3 = encryptedTableV3('v2_read_compat_unrelated_v3', { + note: typesV3.TextEq('note'), +}) + +const SECRET = 'ada@example.com' + +// A v2-mode client: the explicit `eqlVersion: 2` escape hatch forces v2 wire so +// this suite mints genuinely-v2 payloads, independent of schema auto-detection. +let v2Client: EncryptionClient + +// The client a customer is left with after the removal: v3 schemas, default +// (v3) wire version. Every read in #1c goes through this one. +// +// Typed through a thunk rather than annotated `EncryptionClient`: a concrete v3 +// schema set selects the TYPED overload, and `TypedEncryptionClient` is not +// assignable to the nominal `EncryptionClient`. +const makeV3Client = () => Encryption({ schemas: [unrelatedV3] }) +let v3Client: Awaited<ReturnType<typeof makeV3Client>> + +beforeAll(async () => { + v2Client = await Encryption({ + schemas: [usersV2], + config: { eqlVersion: 2 }, + }) + v3Client = await Encryption({ schemas: [unrelatedV3] }) +}) + +describe('#1a — core client reads a stored EQL v2 payload', () => { + it('round-trips a v2 ciphertext through decrypt', async () => { + const encrypted = unwrapResult( + await v2Client.encrypt(SECRET, { table: usersV2, column: usersV2.email }), + ) + // Guard against a false pass: it must be an actual ciphertext. + expect(encrypted).toHaveProperty('c') + + const decrypted = unwrapResult(await v2Client.decrypt(encrypted)) + expect(decrypted).toBe(SECRET) + }, 30000) + + it('round-trips a v2 model through decryptModel', async () => { + const encryptedModel = unwrapResult( + await v2Client.encryptModel({ pk: 'a', email: SECRET }, usersV2), + ) + expect(encryptedModel.email).toHaveProperty('c') + + const decrypted = unwrapResult(await v2Client.decryptModel(encryptedModel)) + expect(decrypted).toEqual({ pk: 'a', email: SECRET }) + }, 30000) +}) + +describe('#1b — DynamoDB adapter reads a stored EQL v2 item', () => { + it('decrypts a v2-split DynamoDB item via encryptedDynamoDB.decryptModel', async () => { + // Stage a stored v2 item: encrypt a v2 model, then split it into DynamoDB + // attributes exactly as the (removed-at-the-type-level, retained-at-runtime) + // v2 write path would have — `toEncryptedDynamoItem(..., false)`. + const encryptedModel = unwrapResult( + await v2Client.encryptModel({ pk: 'a', email: SECRET }, usersV2), + ) + const storedV2Item = toEncryptedDynamoItem(encryptedModel, ['email'], false) + + // The email column becomes `email__source` (+ `email__hmac` for equality); + // the plaintext key does not survive the split. + expect(storedV2Item).toHaveProperty('email__source') + expect(storedV2Item).not.toHaveProperty('email') + expect(storedV2Item.pk).toBe('a') + + const dynamo = encryptedDynamoDB({ encryptionClient: v2Client }) + const decrypted = unwrapResult( + await dynamo.decryptModel(storedV2Item, usersV2), + ) + + expect(decrypted).toMatchObject({ pk: 'a', email: SECRET }) + }, 30000) +}) + +// The real-world shape of the compatibility promise: the customer upgraded, so +// their client is v3-configured and knows nothing about the v2 table — but the +// rows already in their database are v2. +describe('#1c — a v3-configured client reads stored EQL v2 payloads', () => { + // The precondition every other case in this block rests on. Without it the + // whole describe can pass vacuously: if v3 detection regressed, + // `resolveEqlVersion` would return `undefined` rather than throw, leaving + // `v3Client` on the FFI's v2 default — and a v2-mode client reads v2 payloads + // natively, so every assertion below would still be green while the thing + // under test (a *v3* client reading v2) was never exercised. `v3Client` is + // typed through a thunk, so the overload collapse wouldn't surface as a type + // error either. The credential-free half of this guard — the detection matrix + // itself — is `__tests__/resolve-eql-version.test.ts`. + it('is genuinely in EQL v3 mode, or the cases below prove nothing', async () => { + const v3Payload = unwrapResult( + await v3Client.encrypt('a v3-authored note', { + table: unrelatedV3, + column: unrelatedV3.note, + }), + ) + + expect(v3Payload).toMatchObject({ + v: 3, + i: { t: 'v2_read_compat_unrelated_v3', c: 'note' }, + }) + // The structural discriminator, and the stronger half of this guard: a v2 + // scalar carries `k: 'ct'`, a v3 scalar carries no `k` at all (see the + // narrowing note in `src/types.ts`). This still catches a regression that + // somehow preserved `v`. + expect(v3Payload).not.toHaveProperty('k') + }, 30000) + + it('decrypts a v2 ciphertext minted before the upgrade', async () => { + const encrypted = unwrapResult( + await v2Client.encrypt(SECRET, { table: usersV2, column: usersV2.email }), + ) + // Guard against a false pass: this must be a genuine v2 payload, or the + // test proves nothing about v2 compatibility. + expect(encrypted).toMatchObject({ v: 2 }) + + const decrypted = unwrapResult(await v3Client.decrypt(encrypted)) + expect(decrypted).toBe(SECRET) + }, 30000) + + it('decrypts a v2 model minted before the upgrade', async () => { + const encryptedModel = unwrapResult( + await v2Client.encryptModel({ pk: 'a', email: SECRET }, usersV2), + ) + expect(encryptedModel.email).toMatchObject({ v: 2 }) + + const decrypted = unwrapResult(await v3Client.decryptModel(encryptedModel)) + expect(decrypted).toEqual({ pk: 'a', email: SECRET }) + }, 30000) + + it('bulk-decrypts v2 ciphertexts minted before the upgrade', async () => { + const encrypted = unwrapResult( + await v2Client.bulkEncrypt( + [ + { id: '1', plaintext: SECRET }, + { id: '2', plaintext: 'grace@example.com' }, + ], + { table: usersV2, column: usersV2.email }, + ), + ) + + const decrypted = unwrapResult(await v3Client.bulkDecrypt(encrypted)) + expect(decrypted).toEqual([ + { id: '1', data: SECRET }, + { id: '2', data: 'grace@example.com' }, + ]) + }, 30000) + + it('decrypts a stored v2 DynamoDB item through a v3-configured adapter', async () => { + const encryptedModel = unwrapResult( + await v2Client.encryptModel({ pk: 'a', email: SECRET }, usersV2), + ) + const storedV2Item = toEncryptedDynamoItem(encryptedModel, ['email'], false) + + // The adapter is built on the v3 client; only the TABLE argument still + // describes the legacy v2 shape, because that is what the item on disk is. + const dynamo = encryptedDynamoDB({ encryptionClient: v3Client }) + const decrypted = unwrapResult( + await dynamo.decryptModel(storedV2Item, usersV2), + ) + + expect(decrypted).toMatchObject({ pk: 'a', email: SECRET }) + }, 30000) +}) diff --git a/packages/stack/package.json b/packages/stack/package.json index 32a167b60..1f3b5b0c5 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -198,6 +198,7 @@ "db:eql-v3:install": "tsx scripts/install-eql-v3.ts", "test": "vitest run", "test:types": "vitest --run --typecheck.only", + "test:types:dist": "tsc --noEmit -p dist-types/tsconfig.json && tsc --noEmit -p dist-types/node16/tsconfig.json", "release": "tsup", "test:integration": "vitest run --config integration/vitest.config.ts" }, diff --git a/packages/stack/src/adapter-kit.ts b/packages/stack/src/adapter-kit.ts index 2d1fad591..a46706d8d 100644 --- a/packages/stack/src/adapter-kit.ts +++ b/packages/stack/src/adapter-kit.ts @@ -26,7 +26,6 @@ export { // Audit config carried by chainable operations. export type { AuditConfig } from './encryption/operations/base-operation.js' - // v3 column model + the date-like cast set the Supabase builder uses to // reconstruct `Date` values from PostgREST select aliases. export { @@ -34,7 +33,6 @@ export { DATE_LIKE_CASTS, EncryptedV3Column, } from './eql/v3/columns.js' - // Domain registry: the Supabase adapter classifies introspected columns by their // Postgres domain and rebuilds each column's encryption config from it. export { @@ -42,7 +40,6 @@ export { factoryForDomain, stripDomainSchema, } from './eql/v3/domain-registry.js' - // Shared JSONPath-selector path handling (parse/validate, needle-document // reconstruction, scalar-leaf guard) for encrypted-JSON querying — used by the // Drizzle selector operators (#651) and the Supabase selector filters (#650) so @@ -53,8 +50,15 @@ export { reconstructSelectorDocument, unsupportedLeafReason, } from './eql/v3/selector-path.js' - // Shared match-index guard (short-needle rejection), reused by both adapters. export { matchNeedleError } from './schema/match-defaults.js' +// The canonical EQL v2/v3 table discriminator. Added because the adapters must +// make the same routing decision core does, and that function's own doctrine is +// that every site routes through it — "a second hand-written spelling of the +// check is how a v2 envelope eventually gets built for a v3 table once the +// marker drifts" (`types.ts:268-275`). Re-exported here rather than promoted to +// `./types`: deciding which wire version a table targets is adapter plumbing, +// not something an end user should be reaching for. +export { hasBuildColumnKeyMap } from './types.js' // Shared structured logger (adapters log rejections through the same instance). export { logger } from './utils/logger/index.js' diff --git a/packages/stack/src/dynamodb/helpers.ts b/packages/stack/src/dynamodb/helpers.ts index 68966d409..4ee8efa59 100644 --- a/packages/stack/src/dynamodb/helpers.ts +++ b/packages/stack/src/dynamodb/helpers.ts @@ -86,29 +86,33 @@ export function handleError( /** * Resolve a decrypt call against either client shape. * - * The nominal `EncryptionClient` returns a chainable operation that carries - * `.audit()`; the `TypedEncryptionClient` from `EncryptionV3` returns a plain - * `Promise<Result<…>>`. Chain the audit metadata when the client can carry it, - * otherwise await the promise directly — a typed client has no audit surface - * on decrypt, so the metadata has nowhere to go. + * The nominal `EncryptionClient` and the typed client both return a chainable + * operation carrying `.audit()` on decrypt (the typed client's is a + * `MappedDecryptOperation`). Chain the audit metadata onto it. + * + * NOT every client this package accepts does that. `WasmEncryptionClient` + * (`@cipherstash/stack/wasm-inline` — the documented entry for Deno, Workers + * and Supabase Edge Functions) returns a bare promise from decrypt, so it takes + * the branch below and its audit metadata is dropped. It ships in this package + * and satisfies `DynamoDBEncryptionClient` structurally, so it is accepted + * without a cast (#772 review, finding 10). */ export async function resolveDecryptResult<T>( operation: unknown, auditData: { metadata?: Record<string, unknown> }, ): Promise< - { data: T; failure?: never } | { data?: never; failure: DecryptFailure } + { data: T; failure?: never } | { data?: never; failure: ResultFailure } > { const chainable = operation as { audit?: (data: { metadata?: Record<string, unknown> }) => unknown } if (typeof chainable?.audit !== 'function' && auditData.metadata) { - // The typed EncryptionV3 client returns a plain promise with no decrypt - // audit surface, so the metadata has nowhere to go. Make the drop - // observable rather than silent — use the nominal client for audited - // decrypts. + // Reached by the wasm-inline client (bare promise, no `.audit()`) and by + // any custom client whose decrypt returns something else. There is nowhere + // to put the metadata, so make the drop observable rather than silent. logger.debug( - 'DynamoDB: decrypt audit metadata ignored — the typed client has no decrypt audit surface; use Encryption({ config: { eqlVersion: 3 } }) for audited decrypts.', + "DynamoDB: decrypt audit metadata ignored — this client's decrypt does not return a chainable operation with .audit(). Audited decrypts need a client from the default @cipherstash/stack entry; the wasm-inline client's decrypt returns a plain promise.", ) } @@ -135,10 +139,74 @@ export async function resolveDecryptResult<T>( return resolved as | { data: T; failure?: never } - | { data?: never; failure: DecryptFailure } + | { data?: never; failure: ResultFailure } } -type DecryptFailure = { message: string; code?: string } +/** + * The failure member both resolvers hand back — shared, because the encrypt and + * decrypt paths return structurally identical failures and `throwPreservingCode` + * consumes either. `code` is the FFI error code, preserved so `handleError` can + * read it back off the rethrown Error (the error-code contract in AGENTS.md). + */ +type ResultFailure = { message: string; code?: string } + +/** + * Resolve an encrypt call against either client shape — the write-path mirror + * of {@link resolveDecryptResult}. + * + * Both paths face the same split, and only the read one handled it. The native + * clients return a thenable operation carrying `.audit()`; the WASM client's + * `encryptModel` / `bulkEncryptModels` return a plain `Promise<WasmResult>` + * from `wasmResult`, with no `.audit()` on this entry at all. Chaining it + * unconditionally threw `client.encryptModel(...).audit is not a function`, + * which `withResult` caught and reported as a `DYNAMODB_ENCRYPTION_ERROR` — + * so every v3 write through this adapter on the wasm entry looked like a + * genuine encryption fault (#788 review follow-up). + * + * Audit metadata still has nowhere to go on that shape, so it is dropped, and + * the drop is logged rather than silent — exactly as on decrypt. + */ +export async function resolveEncryptResult<T>( + operation: unknown, + auditData: { metadata?: Record<string, unknown> }, + context: 'encryptModel' | 'bulkEncryptModels', +): Promise< + { data: T; failure?: never } | { data?: never; failure: ResultFailure } +> { + const chainable = operation as { + audit?: (data: { metadata?: Record<string, unknown> }) => unknown + } + + if (typeof chainable?.audit !== 'function' && auditData.metadata) { + logger.debug( + `DynamoDB: ${context} audit metadata ignored — this client's encrypt does not return a chainable operation with .audit(). Audited encrypts need a client from the default @cipherstash/stack entry; the wasm-inline client's encrypt returns a plain promise.`, + ) + } + + const resolved = + typeof chainable?.audit === 'function' + ? await chainable.audit(auditData) + : await operation + + // Same fail-closed check the read path applies: a bare value has neither + // `data` nor `failure`, and casting it through would surface a fake success + // carrying `undefined` — here, an "encrypted" item that was never encrypted. + if ( + resolved === null || + typeof resolved !== 'object' || + (!('data' in resolved) && !('failure' in resolved)) + ) { + return { + failure: { + message: `DynamoDB: ${context} returned a malformed result — expected { data } or { failure }.`, + }, + } + } + + return resolved as + | { data: T; failure?: never } + | { data?: never; failure: ResultFailure } +} /** * Rethrow a Result failure as an `Error` that preserves the FFI error code. diff --git a/packages/stack/src/dynamodb/index.ts b/packages/stack/src/dynamodb/index.ts index bce47df8b..bc4c5194f 100644 --- a/packages/stack/src/dynamodb/index.ts +++ b/packages/stack/src/dynamodb/index.ts @@ -31,17 +31,42 @@ import type { * hand: the operation methods below, when a table is supplied. `encryptedDynamoDB` * itself receives no table, so it cannot check earlier. * - * RESIDUAL GAP: a client explicitly forced to `eqlVersion: 2` over a v3 schema - * set (a deliberate migration path) DOES register the table yet emits v2 wire; - * that is not detectable without a wire-version accessor the client does not - * provide, so it is out of scope here. + * The one case this could not detect — a client forced to `eqlVersion: 2` while + * registering a v3 table, emitting v2 wire for an `eql_v3_*` domain — is now + * refused by `resolveEqlVersion` at client construction, so no such client can + * reach here. */ function assertClientTableVersionMatch( encryptionClient: EncryptedDynamoDBConfig['encryptionClient'], table: AnyEncryptedTable, ): void { // Only v3 tables carry the strict wire-format requirement this guards. - if (!isV3Table(table)) return + if (!isV3Table(table)) { + // The v2 read path calls `decryptModel(item)` with NO table on purpose — + // a v2 table means nothing to a v3 client's reconstructor map. That is + // fine for the native clients, which derive the table from the payloads, + // and impossible for the WASM client, whose decrypt requires the table and + // otherwise throws a TypeError about `tableName` from deep inside + // `requireTable`. Refuse the pairing here, where the message can name it + // (#772 review, finding 10). + // + // The message is deliberately operation-NEUTRAL. This guard runs on all + // four operations, so a plain-JS caller (the write overloads are + // `AnyV3Table`-only, so TypeScript never reaches this) hits it on encrypt + // too — where "would fail at the first read" names an operation that never + // ran. The pairing is wrong in both directions anyway: `Encryption()` on + // that entry rejects a v2 schema outright, so the client can never hold + // this table (#788 review). + if ( + (encryptionClient as { requiresTableForDecrypt?: boolean }) + .requiresTableForDecrypt + ) { + throw new Error( + `encryptedDynamoDB: the @cipherstash/stack/wasm-inline client cannot be paired with the legacy EQL v2 table "${table.tableName}". That entry is EQL v3 only — Encryption() there rejects a v2 schema, and its model operations require a table it was initialized with — so both encrypt and decrypt would fail on this table. Use the default @cipherstash/stack entry for tables that still hold EQL v2 items, or migrate the table to an EQL v3 schema (types.* domains) and pass that.`, + ) + } + return + } const getEncryptConfig = ( encryptionClient as { @@ -57,7 +82,7 @@ function assertClientTableVersionMatch( if (table.tableName in config.tables) return throw new Error( - `encryptedDynamoDB: EQL version mismatch — the EQL v3 table "${table.tableName}" is not registered with this encryption client, so the client is not in EQL v3 mode for it. A v3 table requires a v3-mode client: build it with EncryptionV3({ schemas: [<table>] }) (or Encryption({ schemas: [<table>], config: { eqlVersion: 3 } })) and pass that client to encryptedDynamoDB. Otherwise encrypt/decrypt fails later inside the FFI with an opaque deserialization error.`, + `encryptedDynamoDB: EQL version mismatch — the EQL v3 table "${table.tableName}" is not registered with this encryption client, so the client is not in EQL v3 mode for it. A v3 table requires a v3-mode client: build it with Encryption({ schemas: [<table>] }) and pass that client to encryptedDynamoDB. Otherwise encrypt/decrypt fails later inside the FFI with an opaque deserialization error.`, ) } @@ -68,9 +93,11 @@ function assertClientTableVersionMatch( * and `bulkDecryptModels` methods that transparently encrypt/decrypt DynamoDB * items according to the provided table schema. * - * Accepts EQL v3 tables (`types.*` domains) and EQL v2 tables - * (`encryptedColumn`/`encryptedField`) alike — the table decides which wire - * format is synthesized on read. + * **Encrypt/write is EQL v3 only** — `encryptModel` / `bulkEncryptModels` accept + * only EQL v3 tables (`types.*` domains). **Decrypt still reads existing EQL v2 + * items**: `decryptModel` / `bulkDecryptModels` continue to accept an EQL v2 + * table (`encryptedColumn`/`encryptedField`) so previously stored v2 data + * remains readable. The table decides which wire format is reconstructed on read. * * Only equality is meaningful on DynamoDB: an `hm` term is stored alongside the * ciphertext as `<attr>__hmac` and can back a key condition. Ordering and @@ -83,7 +110,8 @@ function assertClientTableVersionMatch( * * @example EQL v3 * ```typescript - * import { EncryptionV3, encryptedTable, types } from "@cipherstash/stack/v3" + * import { Encryption } from "@cipherstash/stack" + * import { encryptedTable, types } from "@cipherstash/stack/v3" * import { encryptedDynamoDB } from "@cipherstash/stack/dynamodb" * * const users = encryptedTable("users", { @@ -91,13 +119,13 @@ function assertClientTableVersionMatch( * name: types.Text("name"), // storage only * }) * - * const client = await EncryptionV3({ schemas: [users] }) + * const client = await Encryption({ schemas: [users] }) * const dynamo = encryptedDynamoDB({ encryptionClient: client }) * * const encrypted = await dynamo.encryptModel({ email: "a@b.com" }, users) * ``` * - * @example EQL v2 (existing deployments) + * @example EQL v2 (reading existing deployments — decrypt only) * ```typescript * import { Encryption } from "@cipherstash/stack" * import { encryptedDynamoDB } from "@cipherstash/stack/dynamodb" diff --git a/packages/stack/src/dynamodb/operations/bulk-decrypt-models.ts b/packages/stack/src/dynamodb/operations/bulk-decrypt-models.ts index d6d8b8b3f..4f1f4f21a 100644 --- a/packages/stack/src/dynamodb/operations/bulk-decrypt-models.ts +++ b/packages/stack/src/dynamodb/operations/bulk-decrypt-models.ts @@ -4,6 +4,7 @@ import { logger } from '@/utils/logger' import { buildReadContext, handleError, + isV3Table, resolveDecryptResult, throwPreservingCode, toItemWithEqlPayloads, @@ -53,9 +54,13 @@ export class BulkDecryptModelsOperation< const client = this.encryptionClient as CallableEncryptionClient const decryptResult = await resolveDecryptResult<Decrypted<T>[]>( - // The second argument is required by the typed client and ignored by - // the nominal one, which derives the table from the payloads. - client.bulkDecryptModels(itemsWithEqlPayloads, this.table), + // Conditional for the same reason as `decryptModel` — see the note + // there. A v2 table forwarded to a v3-configured typed client is + // rejected by its reconstructor lookup, breaking the v2 read path + // this adapter documents as supported. + isV3Table(this.table) + ? client.bulkDecryptModels(itemsWithEqlPayloads, this.table) + : client.bulkDecryptModels(itemsWithEqlPayloads), this.getAuditData(), ) diff --git a/packages/stack/src/dynamodb/operations/bulk-encrypt-models.ts b/packages/stack/src/dynamodb/operations/bulk-encrypt-models.ts index eafee15aa..063d83545 100644 --- a/packages/stack/src/dynamodb/operations/bulk-encrypt-models.ts +++ b/packages/stack/src/dynamodb/operations/bulk-encrypt-models.ts @@ -5,6 +5,7 @@ import { deepClone, handleError, isV3Table, + resolveEncryptResult, throwPreservingCode, toEncryptedDynamoItem, } from '../helpers' @@ -43,12 +44,16 @@ export class BulkEncryptModelsOperation< return await withResult( async () => { const client = this.encryptionClient as CallableEncryptionClient - const encryptResult = await client - .bulkEncryptModels( + const encryptResult = await resolveEncryptResult< + Record<string, unknown>[] + >( + client.bulkEncryptModels( this.items.map((item) => deepClone(item)), this.table, - ) - .audit(this.getAuditData()) + ), + this.getAuditData(), + 'bulkEncryptModels', + ) if (encryptResult.failure) { throwPreservingCode(encryptResult.failure) diff --git a/packages/stack/src/dynamodb/operations/decrypt-model.ts b/packages/stack/src/dynamodb/operations/decrypt-model.ts index aea746b36..1a725e56e 100644 --- a/packages/stack/src/dynamodb/operations/decrypt-model.ts +++ b/packages/stack/src/dynamodb/operations/decrypt-model.ts @@ -3,6 +3,7 @@ import type { Decrypted, EncryptedValue } from '@/types' import { logger } from '@/utils/logger' import { handleError, + isV3Table, resolveDecryptResult, throwPreservingCode, toItemWithEqlPayloads, @@ -47,9 +48,15 @@ export class DecryptModelOperation< const client = this.encryptionClient as CallableEncryptionClient const decryptResult = await resolveDecryptResult<Decrypted<T>>( - // The second argument is required by the typed client and ignored by - // the nominal one, which derives the table from the payloads. - client.decryptModel(withEqlPayloads, this.table), + // The typed client REQUIRES the table; the nominal one derives it + // from the payloads and needs no second argument. Forwarding a v2 + // table unconditionally breaks this adapter's documented v2 read + // path: a v3-configured typed client looks the table up in its own + // reconstructor map, does not find it, and fails. Only a v3 table is + // ever meaningful to that lookup, so only a v3 table is passed. + isV3Table(this.table) + ? client.decryptModel(withEqlPayloads, this.table) + : client.decryptModel(withEqlPayloads), this.getAuditData(), ) diff --git a/packages/stack/src/dynamodb/operations/encrypt-model.ts b/packages/stack/src/dynamodb/operations/encrypt-model.ts index 56a56ea47..18c5840a5 100644 --- a/packages/stack/src/dynamodb/operations/encrypt-model.ts +++ b/packages/stack/src/dynamodb/operations/encrypt-model.ts @@ -5,6 +5,7 @@ import { deepClone, handleError, isV3Table, + resolveEncryptResult, throwPreservingCode, toEncryptedDynamoItem, } from '../helpers' @@ -43,9 +44,13 @@ export class EncryptModelOperation< return await withResult( async () => { const client = this.encryptionClient as CallableEncryptionClient - const encryptResult = await client - .encryptModel(deepClone(this.item), this.table) - .audit(this.getAuditData()) + const encryptResult = await resolveEncryptResult< + Record<string, unknown> + >( + client.encryptModel(deepClone(this.item), this.table), + this.getAuditData(), + 'encryptModel', + ) if (encryptResult.failure) { throwPreservingCode(encryptResult.failure) diff --git a/packages/stack/src/dynamodb/types.ts b/packages/stack/src/dynamodb/types.ts index 1bfbca989..c84af7b2c 100644 --- a/packages/stack/src/dynamodb/types.ts +++ b/packages/stack/src/dynamodb/types.ts @@ -21,9 +21,14 @@ import type { EncryptModelOperation } from './operations/encrypt-model' * `encryptedColumn`/`encryptedField` from `@cipherstash/stack/schema`) or an * EQL v3 one (`encryptedTable` + `types.*` from `@cipherstash/stack/eql/v3`). * - * Both are supported deliberately. DynamoDB shares none of the v2 Postgres - * machinery — there is no EQL extension to install and no migration to run — - * so accepting v3 is purely additive and no existing caller has to change. + * This union is the adapter's widest input type — the erased view the internal + * `CallableEncryptionClient` is declared against. It is NOT the public contract: + * the surface split the two versions apart. `encryptModel` / + * `bulkEncryptModels` narrowed to `AnyV3Table` (the v2 write overloads were + * removed, so a v2 encrypt call site does have to change); `decryptModel` / + * `bulkDecryptModels` still take either, so items stored under v2 stay + * readable. See {@link EncryptedDynamoDBInstance} for the overloads that decide + * this per method. */ export type AnyEncryptedTable = | EncryptedTable<EncryptedTableColumn> @@ -37,12 +42,23 @@ export type AnyEncryptedTable = * nominal `TypedEncryptionClient<S>` parameter would reject a client built for * a narrower schema tuple. * - * The two clients differ at runtime on the decrypt paths — the nominal client - * returns a chainable operation carrying `.audit()`, the typed wrapper returns - * a plain `Promise<Result<…>>` and takes the table as a second argument. The - * operation classes handle both; see `DecryptModelOperation`. The consequence - * for callers is that **audit metadata on decrypt requires the nominal - * client** — with a client from `EncryptionV3` there is nowhere to put it. + * Both NATIVE clients return a chainable operation on every path — the nominal + * client's `DecryptModelOperation` and the typed wrapper's + * `MappedDecryptOperation` each carry `.audit()` (the typed wrapper also takes + * the table as a second argument). The operation classes handle both; see + * `DecryptModelOperation` and `resolveDecryptResult`. + * + * The wasm-inline client does not, on EITHER path: its encrypt and decrypt are + * plain `async` methods returning a bare `Promise<WasmResult>`, so audit + * metadata is dropped (observably — `resolveDecryptResult` and + * `resolveEncryptResult` log it). Chaining `.audit()` unconditionally is + * therefore a bug, not just a lost audit record; the encrypt path made exactly + * that mistake and failed every v3 write on this entry (#788 review follow-up). + * + * Its EQL v2 path is refused outright by `assertClientTableVersionMatch` — the + * v2 read relies on calling decrypt WITHOUT a table, and that entry's + * `Encryption()` rejects a v2 schema anyway, so the pairing is wrong in both + * directions. */ export type DynamoDBEncryptionClient = { encryptModel(input: never, table: never): unknown @@ -51,15 +67,6 @@ export type DynamoDBEncryptionClient = { bulkDecryptModels(input: never, table: never): unknown } -type ChainableEncryptOperation<T> = { - audit(data: { - metadata?: Record<string, unknown> - }): PromiseLike< - | { data: T; failure?: never } - | { data?: never; failure: { message: string; code?: string } } - > -} - /** * @internal Callable view of {@link DynamoDBEncryptionClient}. * @@ -69,19 +76,28 @@ type ChainableEncryptOperation<T> = { * satisfy. The operation classes therefore cast to this shape at the call site * — the same split the Drizzle v3 operators use. * - * `decryptModel` is intentionally untyped in its return: the nominal client - * returns a chainable operation, the typed client a plain promise. See - * `resolveDecryptResult`. + * The returns are intentionally untyped on ALL FOUR members. The clients + * disagree about what an operation even is: the nominal client returns a + * chainable `EncryptModelOperation` / `DecryptModelOperation`, the typed client + * a `MappedDecryptOperation`, and the wasm-inline client a bare + * `Promise<WasmResult>` with no `.audit()` anywhere on it. + * + * Declaring a chainable shape here asserts an `.audit()` that the wasm entry + * does not have, and that assertion was not academic — it is exactly what let + * the write path chain `.audit()` unconditionally and fail EVERY EQL v3 write on + * that entry (#788 review follow-up). `unknown` forces each call site through + * `resolveEncryptResult` / `resolveDecryptResult`, which normalise all three + * shapes and fail closed on anything else. */ export type CallableEncryptionClient = { encryptModel( input: Record<string, unknown>, table: AnyEncryptedTable, - ): ChainableEncryptOperation<Record<string, unknown>> + ): unknown bulkEncryptModels( input: Record<string, unknown>[], table: AnyEncryptedTable, - ): ChainableEncryptOperation<Record<string, unknown>[]> + ): unknown decryptModel( input: Record<string, unknown>, table?: AnyEncryptedTable, @@ -94,10 +110,9 @@ export type CallableEncryptionClient = { export interface EncryptedDynamoDBConfig { /** - * Either the nominal client from `Encryption(...)` / `Encryption({ schemas, - * config: { eqlVersion: 3 } })`, or the typed client from `EncryptionV3(...)`. - * For EQL v3 tables the client must be in v3 mode — `EncryptionV3` forces - * this; with `Encryption` you must pass `config: { eqlVersion: 3 }` yourself. + * The client from `Encryption(...)` (or the deprecated `EncryptionV3(...)` + * alias). For an EQL v3 schema set `Encryption` auto-selects the v3 wire format + * and returns the typed client — no `config: { eqlVersion: 3 }` needed. */ encryptionClient: EncryptionClient | DynamoDBEncryptionClient options?: { @@ -169,9 +184,9 @@ type Simplify<T> = { [K in keyof T]: T[K] } * * A declared column `email` does NOT survive as `email`: the adapter deletes it * and writes `email__source` (plus `email__hmac` for equality domains). Typing - * the result as the input model — what the v2 overload still does — is a lie - * that type-checks `result.data.email` (always `undefined` at runtime) and - * rejects `result.data.email__source` (the value you actually want). + * the result as the input model is a lie that type-checks `result.data.email` + * (always `undefined` at runtime) and rejects `result.data.email__source` (the + * value you actually want). * * Keys that name no column pass through untouched — partition/sort keys, GSI * attributes, anything else on the item. @@ -240,16 +255,6 @@ export interface EncryptedDynamoDBInstance { item: V3ModelInput<Table, T>, table: Table, ): EncryptModelOperation<EncryptedAttributes<Table, T>> - /** - * EQL v2. Unchanged, so existing callers keep compiling — v2 columns do not - * carry their index configuration in the type, so the storage split cannot be - * derived. The returned `T` is the INPUT model, not what is on the wire; read - * `<attr>__source` / `<attr>__hmac` through a type of your own. - */ - encryptModel<T extends Record<string, unknown>>( - item: T, - table: EncryptedTable<EncryptedTableColumn>, - ): EncryptModelOperation<T> /** EQL v3. See {@link EncryptedDynamoDBInstance.encryptModel}. */ bulkEncryptModels< @@ -259,11 +264,6 @@ export interface EncryptedDynamoDBInstance { items: Array<V3ModelInput<Table, T>>, table: Table, ): BulkEncryptModelsOperation<EncryptedAttributes<Table, T>> - /** EQL v2. See {@link EncryptedDynamoDBInstance.encryptModel}. */ - bulkEncryptModels<T extends Record<string, unknown>>( - items: T[], - table: EncryptedTable<EncryptedTableColumn>, - ): BulkEncryptModelsOperation<T> /** * EQL v3: `item` is the stored attribute map (`<col>__source` / diff --git a/packages/stack/src/encryption/helpers/index.ts b/packages/stack/src/encryption/helpers/index.ts index 8b431d8d2..74c59f1a2 100644 --- a/packages/stack/src/encryption/helpers/index.ts +++ b/packages/stack/src/encryption/helpers/index.ts @@ -11,10 +11,9 @@ import type { Encrypted, EncryptedQueryResult, KeysetIdentifier } from '@/types' * payload, or a v3 ciphertext-free scalar/SteVec query term (including the * bare selector hash and `eql_v3.query_json` containment needle). * - * TODO: duplicated in `@cipherstash/protect` — see - * `packages/protect/src/helpers/index.ts`. Both copies should be removed once - * `@cipherstash/protect-ffi` exports a named alias for the `encryptQuery` - * return type (https://github.com/cipherstash/stack/pull/473). + * TODO: replace this local union once `@cipherstash/protect-ffi` exports a + * named alias for the `encryptQuery` return type + * (https://github.com/cipherstash/stack/pull/473). */ type EncryptedQueryTerm = | CipherStashEncrypted diff --git a/packages/stack/src/encryption/index.ts b/packages/stack/src/encryption/index.ts index a66817e79..079ac2b28 100644 --- a/packages/stack/src/encryption/index.ts +++ b/packages/stack/src/encryption/index.ts @@ -1,6 +1,7 @@ import { type Result, withResult } from '@byteslice/result' import { newClient } from '@cipherstash/protect-ffi' import { validate as uuidValidate } from 'uuid' +import type { AnyV3Table } from '@/eql/v3' import { type EncryptionError, EncryptionErrorTypes } from '@/errors' // `LockContext` is imported type-only so the TSDoc {@link} references in the // comments below resolve; it is erased at compile time. @@ -20,6 +21,7 @@ import type { BulkDecryptPayload, BulkEncryptPayload, Client, + ClientConfig, Encrypted, EncryptedFromBuildableTable, EncryptionClientConfig, @@ -28,6 +30,7 @@ import type { KeysetIdentifier, Plaintext, ScalarQueryTerm, + V3ClientConfig, } from '@/types' import { hasBuildColumnKeyMap } from '@/types' import { logger } from '@/utils/logger' @@ -43,6 +46,13 @@ import { DecryptModelOperation } from './operations/decrypt-model' import { EncryptOperation } from './operations/encrypt' import { EncryptModelOperation } from './operations/encrypt-model' import { EncryptQueryOperation } from './operations/encrypt-query' +// `typedClient` wraps the nominal client into the strongly-typed EQL v3 client. +// This is a deliberate circular import with `./v3` (which imports `Encryption` +// from here): both `Encryption` and `typedClient` are hoisted `function` +// declarations, and the only module-eval cross-reference — `EncryptionV3 = +// Encryption` in `./v3` — reads the hoisted `Encryption`, so neither binding is +// observed uninitialised regardless of which module evaluates first. +import { type TypedEncryptionClient, typedClient } from './v3' // Re-export the operation classes returned by EncryptionClient methods so they // are part of the public API and appear in the generated reference, allowing @@ -79,9 +89,10 @@ export const noClientError = () => * columns and the v3 tables target `eql_v3` domains, so no single wire * format serves both. Split them across two clients. * - * An explicit `config.eqlVersion` bypasses version detection (the wire format - * is then unambiguous — e.g. writing v2 wire from a v3 schema set during a - * migration), but mixed schemas and legacy v2 SteVec schemas still throw. + * An explicit `config.eqlVersion` bypasses DETECTION, not validation: mixed + * schemas and legacy v2 SteVec schemas still throw, and so does an explicit `2` + * over an all-v3 set. What survives is `2` over a v2 schema set — minting v2 + * wire during a migration. * * @internal exported for unit-test coverage of the detection matrix. */ @@ -114,6 +125,28 @@ export function resolveEqlVersion( ) } + // An explicit 2 over a set that is ENTIRELY v3 is a contradiction, and a + // silent one: the FFI client emits `eql_v2_encrypted` payloads for columns + // whose Postgres domain is `eql_v3_*`, so the write either trips the domain + // CHECK or lands v2 wire wherever the check is looser. + // + // `EncryptionV3` used to prevent this by forcing `eqlVersion: 3` over + // whatever the caller passed — its comment said so explicitly. It is now a + // bare alias of `Encryption`, so a caller upgrading from + // `EncryptionV3({ schemas: [v3Table], config: { eqlVersion: 2 } })` — which + // was silently corrected and worked — now gets a v2-wire client with no + // diagnostic at any layer (#772 review, finding 8). + // + // The escape hatch itself stays: an explicit 2 over a V2 schema set is how + // `integration/shared/v2-decrypt-compat.integration.test.ts` mints the + // fixtures that prove v2 payloads still decrypt. That is the only shape that + // uses it — nothing mints v2 wire from a v3 table. + if (explicit === 2 && schemas.length > 0 && v3Count === schemas.length) { + throw new Error( + "[encryption]: cannot emit EQL v2 wire for a schema set that is entirely EQL v3 — the payloads would not match the columns' eql_v3_* domains. Drop `config.eqlVersion` to emit v3, or build the client from the EQL v2 schema you actually want to write.", + ) + } + if (explicit !== undefined) { return explicit } @@ -852,9 +885,69 @@ export function __resetStrategyDeprecationWarningForTests(): void { * @see {@link ClientConfig.authStrategy} for the auth strategy field. * @see {@link EncryptionClient} for available methods after initialization. */ -export const Encryption = async ( +/** + * The schema-tuple guard for {@link Encryption}'s v3 overload. + * + * Resolves to `S` for any non-empty array of v3 tables and to `never` for + * `readonly []`, so `Encryption({ schemas: [] })` stays a compile error while + * every non-literal form (a shared `AnyV3Table[]`, a `ReadonlyArray`, a + * push-built or spread array) still selects this overload. + */ +type NonEmptyV3<S extends readonly AnyV3Table[]> = S['length'] extends 0 + ? never + : S + +// Overload 1 — v3-typed: an array literal of concrete EQL v3 tables (from +// `@cipherstash/stack/v3`) yields the strongly-typed {@link TypedEncryptionClient}, +// the collapse of the former `EncryptionV3`. The wire format is forced to v3. +// +// `S` is the ARRAY, not a non-empty tuple. Constraining the type parameter to +// `readonly [AnyV3Table, ...AnyV3Table[]]` — which is how the `[]` case was +// first closed — rejected every form that is not an array literal: a shared +// `export const all: AnyV3Table[]`, the `ReadonlyArray` prisma-next exposes, +// anything push-built or spread. Non-emptiness is enforced on the PROPERTY via +// {@link NonEmptyV3} instead, which leaves `Encryption({ schemas: [] })` a +// compile error without constraining the rest. +// +// `NonEmptyV3<S>` must sit on `schemas` and nowhere else: wrapping the whole +// config in a conditional alias defeats `const` inference, degrading the tuple +// to an array and erasing per-column plaintext typing on the literal path. +// +// It also cannot be the ONLY v3 signature, which is why there are two. A type +// parameter is assignable to a deferred conditional only if it is assignable to +// BOTH branches, and one branch here is `never` — so `NonEmptyV3<S>` rejects +// every caller that is itself generic over its schemas: +// +// async function make<S extends readonly [AnyV3Table, ...AnyV3Table[]]>(schemas: S) { +// return await Encryption({ schemas }) // TS2769 against `NonEmptyV3<S>` +// } +// +// That shape compiled before A-4 and is the one the `EncryptionClientFor` docs +// point generic code at, so it must keep compiling. The overload below carries +// the non-emptiness in its CONSTRAINT instead of a conditional, which a generic +// `S` can satisfy; the widened one after it then only has to serve arrays that +// are concrete at the call site, where the conditional resolves eagerly. +export function Encryption< + const S extends readonly [AnyV3Table, ...AnyV3Table[]], +>(config: { + schemas: S + config?: V3ClientConfig +}): Promise<TypedEncryptionClient<S>> +export function Encryption<const S extends readonly AnyV3Table[]>(config: { + schemas: NonEmptyV3<S> + config?: V3ClientConfig +}): Promise<TypedEncryptionClient<S>> +// Overload 2 — nominal: loose/dynamic schemas (introspection-derived, e.g. +// stack-supabase) or EQL v2 tables yield the generation-neutral +// {@link EncryptionClient}, which auto-detects its wire version. Declared LAST +// so `Parameters<typeof Encryption>` / `ReturnType<typeof Encryption>` resolve +// to this signature (stack-supabase casts its schemas against it). +export function Encryption( config: EncryptionClientConfig, -): Promise<EncryptionClient> => { +): Promise<EncryptionClient> +export async function Encryption( + config: EncryptionClientConfig, +): Promise<unknown> { const { schemas, config: clientConfig } = config if (!schemas.length) { @@ -885,10 +978,17 @@ export const Encryption = async ( const client = new EncryptionClient() const encryptConfig = buildEncryptConfig(...schemas) - // Resolve the wire format for this client: an explicit - // `config.eqlVersion` wins; otherwise it is auto-detected from the - // schema set (v3 tables → 3, v2 tables → the FFI's v2 default). A mixed - // v2 + v3 schema set throws — one client emits exactly one wire format. + // A schema set of exclusively concrete EQL v3 tables is the typed-client path + // (the collapse of the former `EncryptionV3`). Every other set — EQL v2 tables, + // or the introspection-derived loose tables stack-supabase passes — stays + // nominal. + const isV3Only = schemas.every(hasBuildColumnKeyMap) + + // Resolve the wire format: an explicit `config.eqlVersion` wins (the retained + // migration escape hatch — `2` over a v2 schema set), otherwise it is + // auto-detected (v3 tables → 3, v2 tables → the FFI's v2 default). A mixed + // v2 + v3 set, and an explicit `2` over an all-v3 set, throw inside + // `resolveEqlVersion`. const eqlVersion = resolveEqlVersion(schemas, clientConfig?.eqlVersion) const result = await client.init({ @@ -902,5 +1002,15 @@ export const Encryption = async ( throw new Error(`[encryption]: ${result.failure.message}`) } + // Return the typed client only when the client is genuinely in EQL v3 mode: an + // all-v3 schema set that resolved to the v3 wire format. The `eqlVersion === 3` + // conjunct is now belt-and-braces: `resolveEqlVersion` refuses an explicit `2` + // over an all-v3 set, so an all-v3 set cannot reach here in v2 mode. + if (isV3Only && eqlVersion === 3) { + // biome-ignore lint/plugin: the runtime `isV3Only` guard (every schema has + // buildColumnKeyMap) proves these are AnyV3Table — the compiler can't see it. + const v3Schemas = schemas as unknown as readonly AnyV3Table[] + return typedClient(result.data, ...v3Schemas) + } return result.data } diff --git a/packages/stack/src/encryption/operations/mapped-decrypt.ts b/packages/stack/src/encryption/operations/mapped-decrypt.ts new file mode 100644 index 000000000..e3ee40b50 --- /dev/null +++ b/packages/stack/src/encryption/operations/mapped-decrypt.ts @@ -0,0 +1,109 @@ +import type { Result } from '@byteslice/result' +import type { EncryptionError } from '@/errors' +import type { LockContextInput } from '@/identity' +import { type AuditConfig, EncryptionOperation } from './base-operation' + +/** + * The subset of an underlying decrypt operation the mapped wrapper drives: a + * chainable {@link EncryptionOperation} that MAY additionally expose + * `.withLockContext()`. A lock-bound underlying op (e.g. + * `DecryptModelOperationWithLockContext`) has already consumed its lock context + * and offers no further `withLockContext`, so the method is optional here. + */ +type UnderlyingDecryptOperation<In> = EncryptionOperation<In> & { + withLockContext?: (lockContext: LockContextInput) => EncryptionOperation<In> +} + +/** + * The public contract of a decrypt-model operation returned by the typed client: + * awaitable to a `Result<Out, …>`, and chainable with `.audit()` / + * `.withLockContext()`. Hides the underlying pre-map type parameter. + */ +export interface AuditableDecryptModelOperation<Out> + extends EncryptionOperation<Out> { + audit(config: AuditConfig): this + /** + * @throws if the operation already took a lock context positionally — bind it + * once, either positionally or by chaining. + */ + withLockContext( + lockContext: LockContextInput, + ): AuditableDecryptModelOperation<Out> +} + +/** + * A chainable decrypt operation that maps a successful result through a pure, + * precomputed function while delegating audit metadata and lock context to the + * underlying operation it wraps. + * + * This is what lets the typed EQL v3 client carry `.audit()` / + * `.withLockContext()` on `decryptModel` / `bulkDecryptModels`: the earlier + * implementation `await`ed the underlying op and mapped the resolved value, + * which collapsed the chain to a plain `Promise<Result<…>>` and dropped both + * capabilities. Here the mapping runs inside `execute()` instead, so the + * operation stays an {@link EncryptionOperation} the whole way. + * + * - `.audit()` forwards to the underlying op, so the op that actually runs the + * decrypt sees the metadata (via `getAuditData()`), in EITHER chaining order — + * `.audit().withLockContext()` propagates because `withLockContext` copies the + * audit data forward, and `.withLockContext().audit()` propagates because the + * wrapper forwards `.audit()` onto the now-lock-bound underlying op. + * - `.withLockContext()` rebuilds the wrapper over `underlying.withLockContext(lc)`, + * preserving the same `map` and unknown-table failure. It throws if the + * underlying op is already lock-bound (a positional lock context followed by a + * chained one), because the wrapper — unlike the nominal path — still exposes + * the method after binding, so the second call would otherwise be dropped. + * - `execute()` never throws: an unknown table (no `map`) returns the precomputed + * `failure` Result, and `map` is a precomputed reconstructor — pure, no + * `build()` — so it cannot reject the Result contract. + */ +export class MappedDecryptOperation<In, Out> extends EncryptionOperation<Out> { + constructor( + private readonly underlying: UnderlyingDecryptOperation<In>, + // Precomputed reconstructor. `undefined` when the table is unknown to the + // client — `execute()` then short-circuits to `unknownTableFailure`. + private readonly map: ((value: In) => Out) | undefined, + private readonly unknownTableFailure: { failure: EncryptionError }, + ) { + super() + } + + override audit(config: AuditConfig): this { + // Delegate to the op that runs the decrypt so its `execute()` sees the + // metadata; the wrapper's own `execute()` reads nothing off `this`. + this.underlying.audit(config) + return this + } + + withLockContext( + lockContext: LockContextInput, + ): MappedDecryptOperation<In, Out> { + // A lock-bound underlying op exposes no `withLockContext` — it has already + // consumed its context. Unlike the nominal path, where the method is simply + // absent after binding, the wrapper always exposes it, so a second bind + // type-checks. Silently keeping the first would drop the caller's intent and + // surface later as an opaque ZeroKMS rejection; reject it here instead. + if (!this.underlying.withLockContext) { + throw new Error( + '[encryption]: this decrypt operation is already bound to a lock context. Pass the lock context positionally OR chain .withLockContext() — not both.', + ) + } + return new MappedDecryptOperation( + this.underlying.withLockContext(lockContext), + this.map, + this.unknownTableFailure, + ) + } + + override async execute(): Promise<Result<Out, EncryptionError>> { + if (!this.map) { + // Fresh Result so no two ops can alias (or mutate) a shared failure object. + return { failure: { ...this.unknownTableFailure.failure } } + } + const result = await this.underlying.execute() + if (result.failure) { + return result + } + return { data: this.map(result.data) } + } +} diff --git a/packages/stack/src/encryption/v3.ts b/packages/stack/src/encryption/v3.ts index 14d934556..cc79cb306 100644 --- a/packages/stack/src/encryption/v3.ts +++ b/packages/stack/src/encryption/v3.ts @@ -15,7 +15,7 @@ import type { LockContextInput } from '@/identity' import type { BulkDecryptPayload, BulkEncryptPayload, - ClientConfig, + Decrypted, Encrypted, EncryptedReturnType, EncryptOptions, @@ -33,6 +33,10 @@ import { type EncryptOperation, type EncryptQueryOperation, } from './index' +import { + type AuditableDecryptModelOperation, + MappedDecryptOperation, +} from './operations/mapped-decrypt' /** * A strongly-typed view over an {@link EncryptionClient} for EQL v3 schemas. @@ -101,22 +105,44 @@ export interface TypedEncryptionClient<S extends readonly AnyV3Table[]> { * columns are reconstructed from the encrypt-config `cast_as`. * * Pass `lockContext` to decrypt identity-bound data — the same context that - * was supplied at encrypt time must be provided here. + * was supplied at encrypt time must be provided here (or chain + * `.withLockContext()` on the returned operation). * - * Unlike the encrypt operations this returns a plain `Promise<Result<…>>` - * rather than a chainable operation, because it maps the resolved value. + * Returns a chainable {@link AuditableDecryptModelOperation}: await it for the + * `Result`, or chain `.audit({ metadata })` / `.withLockContext()` first. The + * per-row `Date` reconstruction is applied to the successful result. */ decryptModel<Table extends S[number], T extends Record<string, unknown>>( input: T, table: Table, lockContext?: LockContextInput, - ): Promise<Result<V3DecryptedModel<Table, T>, EncryptionError>> + ): AuditableDecryptModelOperation<V3DecryptedModel<Table, T>> + + /** + * Table-less form, mirroring the nominal {@link EncryptionClient}: decrypt + * whatever encrypted fields the model carries, with no `Date` reconstruction + * (there is no `cast_as` to reconstruct from) and no precise plaintext shape. + * + * This is the read path for rows that predate this client's schemas — legacy + * **EQL v2** models above all, whose table is not, and cannot be, a member of + * `S`. The runtime has always accepted the one-arg call; without this + * signature the type layer forbids the very compatibility the client + * promises. Prefer the two-arg form whenever the table IS registered. + */ + decryptModel<T extends Record<string, unknown>>( + input: T, + ): AuditableDecryptModelOperation<Decrypted<T>> bulkDecryptModels<Table extends S[number], T extends Record<string, unknown>>( input: Array<T>, table: Table, lockContext?: LockContextInput, - ): Promise<Result<Array<V3DecryptedModel<Table, T>>, EncryptionError>> + ): AuditableDecryptModelOperation<Array<V3DecryptedModel<Table, T>>> + + /** Table-less bulk form — see the one-arg {@link decryptModel} overload. */ + bulkDecryptModels<T extends Record<string, unknown>>( + input: Array<T>, + ): AuditableDecryptModelOperation<Array<Decrypted<T>>> // Parity passthroughs — not v3-strengthened, delegated as-is. bulkEncrypt( @@ -125,6 +151,36 @@ export interface TypedEncryptionClient<S extends readonly AnyV3Table[]> { ): BulkEncryptOperation bulkDecrypt(payloads: BulkDecryptPayload): BulkDecryptOperation getEncryptConfig(): ReturnType<EncryptionClient['getEncryptConfig']> + + /** + * Re-initialize the underlying client, staying on EQL v3. + * + * @internal Present for runtime parity with {@link EncryptionClient}, not as + * part of the typed authoring surface. `Encryption` picks its return value by + * inspecting the *schemas* while overload resolution inspects the *config*, so + * the two can disagree: a config hoisted into a `ClientConfig`-typed variable + * selects the nominal overload but still yields this client at runtime. Every + * other `EncryptionClient` method already existed here; without `init` that + * mismatch turned into `TypeError: client.init is not a function`. + * + * This is NOT a bare passthrough, because `EncryptionClient.init` would + * otherwise be a way around the guard `Encryption` applies at construction. + * `init` takes its own `eqlVersion`, forwards `undefined` to the FFI (whose + * default is **v2**), and never consults `resolveEqlVersion` — so delegating + * verbatim would let a typed v3 client be re-initialized into v2 wire while + * keeping this v3 surface, the exact contradiction `resolveEqlVersion` + * refuses. The wire version is pinned to `3` and an explicit `2` is rejected. + * + * It also resolves to the TYPED client rather than the underlying nominal one, + * so `client = (await client.init(cfg)).data` — the natural idiom, since + * `EncryptionClient.init` resolves `{ data: this }` — keeps the typed surface + * instead of silently degrading to the nominal one. + */ + init( + config: Omit<Parameters<EncryptionClient['init']>[0], 'eqlVersion'> & { + eqlVersion?: 3 + }, + ): Promise<Result<TypedEncryptionClient<S>, EncryptionError>> } /** @@ -214,6 +270,16 @@ export function typedClient<const S extends readonly AnyV3Table[]>( }, } + // Pass-through maps for a one-arg (nominal-style) decrypt call, where `table` + // is absent: decrypt WITHOUT date reconstruction, exactly as the nominal + // `EncryptionClient` does. This client is now what `Encryption` returns for a + // v3 schema set, so a consumer typed against the nominal overload (e.g. + // stack-supabase's query builder, which casts to it) can call `decryptModel(x)` + // / `bulkDecryptModels(xs)` with no table. Degrade gracefully instead of + // dereferencing `undefined.tableName`. + const passthroughRow = (row: Record<string, unknown>) => row + const passthroughRows = (rows: Array<Record<string, unknown>>) => rows + // Overloaded so the implementation is checked against BOTH forms directly — // no whole-value cast. The two public signatures mirror the interface member; // the hidden implementation signature is broad and forwards to the nominal @@ -244,7 +310,85 @@ export function typedClient<const S extends readonly AnyV3Table[]>( return client.encryptQuery(plaintextOrTerms as never, opts as never) } - return { + // Overloaded declarations for the same reason as `encryptQuery` above: the + // table-ful and table-less forms have different return types, and a single + // arrow in the object literal cannot present both. + function decryptModel< + Table extends S[number], + T extends Record<string, unknown>, + >( + input: T, + table: Table, + lockContext?: LockContextInput, + ): AuditableDecryptModelOperation<V3DecryptedModel<Table, T>> + function decryptModel<T extends Record<string, unknown>>( + input: T, + ): AuditableDecryptModelOperation<Decrypted<T>> + function decryptModel( + input: Record<string, unknown>, + table?: AnyV3Table, + lockContext?: LockContextInput, + ): AuditableDecryptModelOperation<never> { + // `table` is absent on a nominal-style one-arg call (see `passthroughRow`). + // Given a table: reconstruct dates from its cast_as, or — if it was never + // registered — leave `map` undefined so the mapped op resolves to + // `unknownTableFailure` on execute. + const reconstruct = table + ? reconstructors.get(table.tableName) + : passthroughRow + const op = client.decryptModel(input) + const base = lockContext ? op.withLockContext(lockContext) : op + return new MappedDecryptOperation( + base, + reconstruct, + unknownTableFailure, + ) as never + } + + function bulkDecryptModels< + Table extends S[number], + T extends Record<string, unknown>, + >( + input: Array<T>, + table: Table, + lockContext?: LockContextInput, + ): AuditableDecryptModelOperation<Array<V3DecryptedModel<Table, T>>> + function bulkDecryptModels<T extends Record<string, unknown>>( + input: Array<T>, + ): AuditableDecryptModelOperation<Array<Decrypted<T>>> + function bulkDecryptModels( + input: Array<Record<string, unknown>>, + table?: AnyV3Table, + lockContext?: LockContextInput, + ): AuditableDecryptModelOperation<never> { + const op = client.bulkDecryptModels(input) + const base = lockContext ? op.withLockContext(lockContext) : op + // No table → pass rows through (nominal behaviour). Registered table → + // reconstruct each row. Unregistered table → `undefined` map → + // `unknownTableFailure` on execute. + let mapRows: + | (( + rows: Array<Record<string, unknown>>, + ) => Array<Record<string, unknown>>) + | undefined + if (!table) { + mapRows = passthroughRows + } else { + const reconstruct = reconstructors.get(table.tableName) + mapRows = reconstruct ? (rows) => rows.map(reconstruct) : undefined + } + return new MappedDecryptOperation( + base, + mapRows, + unknownTableFailure, + ) as never + } + + // Annotated rather than `satisfies`, because `init` resolves to `typed` + // itself and a self-referencing initializer has no inferrable type. The + // annotation checks the same shape the `satisfies` did, and the function's + // declared return type is this type anyway, so nothing is widened. + const typed: TypedEncryptionClient<S> = { encrypt: (plaintext, opts) => client.encrypt(plaintext as never, opts as never), encryptQuery, @@ -253,67 +397,102 @@ export function typedClient<const S extends readonly AnyV3Table[]>( bulkEncryptModels: (input, table) => client.bulkEncryptModels(input as never, table as never) as never, decrypt: (encrypted) => client.decrypt(encrypted), - decryptModel: async (input, table, lockContext) => { - const reconstruct = reconstructors.get(table.tableName) - if (!reconstruct) return unknownTableFailure as never - const op = client.decryptModel(input as never) - const result = await (lockContext ? op.withLockContext(lockContext) : op) - if (result.failure) return result as never - return { data: reconstruct(result.data) } as never - }, - bulkDecryptModels: async (input, table, lockContext) => { - const reconstruct = reconstructors.get(table.tableName) - if (!reconstruct) return unknownTableFailure as never - const op = client.bulkDecryptModels(input as never) - const result = await (lockContext ? op.withLockContext(lockContext) : op) - if (result.failure) return result as never - return { - data: result.data.map((row) => - reconstruct(row as Record<string, unknown>), - ), - } as never - }, + decryptModel, + bulkDecryptModels, bulkEncrypt: (plaintexts, opts) => client.bulkEncrypt(plaintexts, opts), bulkDecrypt: (payloads) => client.bulkDecrypt(payloads), getEncryptConfig: () => client.getEncryptConfig(), - } satisfies TypedEncryptionClient<S> + // See the interface member for why this is not `client.init(config)`: + // `init` bypasses `resolveEqlVersion` and defaults the FFI to v2 wire, so a + // verbatim delegation would reopen the contradiction A-8 closes. Pin v3, + // refuse an explicit 2, and resolve to the typed client so the surface + // survives the round trip. + init: async (config) => { + if (config.eqlVersion !== undefined && config.eqlVersion !== 3) { + return { + failure: { + type: EncryptionErrorTypes.EncryptionError, + message: + "[eql/v3]: cannot re-initialize a typed EQL v3 client at eqlVersion 2 — the payloads would not match the columns' eql_v3_* domains. Build a separate client from the EQL v2 schema you want to write.", + }, + } + } + + const result = await client.init({ ...config, eqlVersion: 3 }) + return result.failure ? result : { data: typed } + }, + } + + return typed } /** - * Build a {@link TypedEncryptionClient} for EQL v3 schemas — the strongly-typed - * counterpart to {@link Encryption}. Mirrors its config, then retypes the client - * against the provided v3 `schemas`. + * The client type {@link Encryption} resolves to for the schema tuple `S`. + * + * This is **the** way to name the client — reach for it whenever you need to + * declare a variable, field or return type before the `await` that produces it: + * + * ```typescript + * const users = encryptedTable("users", { email: types.TextSearch("email") }) + * + * let client: EncryptionClientFor<readonly [typeof users]> + * client = await Encryption({ schemas: [users] }) + * ``` + * + * For code that is generic over its schemas — integration adapters that build a + * table per test family, say — name the loose array and keep the typed surface: + * + * ```typescript + * let client: EncryptionClientFor<readonly AnyV3Table[]> + * ``` + * + * **Do not use `Awaited<ReturnType<typeof Encryption>>`.** `Encryption` is + * overloaded, and TypeScript's `ReturnType` reads the LAST overload — the + * nominal one — so that expression yields `EncryptionClient` even for an all-v3 + * schema set, and assigning the real client to it is an error: + * + * ``` + * Type 'TypedEncryptionClient<…>' is missing the following properties + * from type 'EncryptionClient': client, encryptConfig + * ``` + * + * Overload order cannot fix that — whichever signature is last wins, so one of + * the two forms is always mis-resolved, and putting the nominal signature first + * mis-resolves v3 schemas instead (a v3 table structurally satisfies + * `BuildableTable`). A named extraction type is what every comparable library + * does for the same reason: `z.infer`, arktype's `typeof T.infer`, hono's + * `Client<T>`. + */ +export type EncryptionClientFor<S extends readonly unknown[]> = + S extends readonly AnyV3Table[] + ? S['length'] extends 0 + ? EncryptionClient + : TypedEncryptionClient<S> + : EncryptionClient + +/** + * @deprecated Use {@link Encryption} instead — it is now overloaded so an array + * of concrete EQL v3 tables yields the same strongly-typed client this used to. + * `EncryptionV3` is a type-identical alias of `Encryption`, retained for + * backwards compatibility, and will be removed in a future release. * * @example * ```typescript - * import { EncryptionV3, encryptedTable, types } from "@cipherstash/stack/v3" + * import { Encryption, encryptedTable, types } from "@cipherstash/stack/v3" * * const users = encryptedTable("users", { email: types.TextSearch("email") }) - * const client = await EncryptionV3({ schemas: [users] }) + * const client = await Encryption({ schemas: [users] }) * * await client.encrypt("a@b.com", { table: users, column: users.email }) * ``` */ -export async function EncryptionV3< - const S extends readonly AnyV3Table[], ->(config: { - schemas: S - config?: ClientConfig -}): Promise<TypedEncryptionClient<S>> { - const client = await Encryption({ - schemas: config.schemas as unknown as Parameters< - typeof Encryption - >[0]['schemas'], - // Force the v3 EQL wire format. protect-ffi's newClient defaults to - // eqlVersion 2; a v2-mode client cannot resolve v3 concrete-type columns - // and fails every encrypt with "Cannot convert undefined or null to - // object". This is a v3-only invariant, so it overrides any user value. - config: { ...config.config, eqlVersion: 3 }, - }) - return typedClient(client, ...config.schemas) -} +export const EncryptionV3 = Encryption // Single import surface: re-export the v3 `types` namespace + table API + type // helpers so `@cipherstash/stack/v3` provides everything needed to author and // use a schema. export * from '@/eql/v3' +// `Encryption` comes along for the same reason — it is the current name for +// what `EncryptionV3` aliases, so authoring a v3 schema and building its +// client should not need a second import specifier. +export { Encryption } diff --git a/packages/stack/src/eql/v3/columns.ts b/packages/stack/src/eql/v3/columns.ts index 30c46ce81..faf4a0560 100644 --- a/packages/stack/src/eql/v3/columns.ts +++ b/packages/stack/src/eql/v3/columns.ts @@ -431,7 +431,46 @@ function isQueryableCapabilities(capabilities: QueryCapabilities): boolean { * `EncryptedDateColumn`, both storage-only) are NOT mutually assignable. This * nominality is what keeps plaintext inference precise. */ +/** + * Key for {@link EncryptedV3Column}'s phantom domain carrier. Declared, never + * defined — it exists only in the type layer, and is deliberately not exported. + */ +declare const domainCarrier: unique symbol + export class EncryptedV3Column<D extends V3DomainDefinition> { + /** + * Phantom carrier for `D`, and the reason this class is usable from the + * BUILT package at all. + * + * `PlaintextForColumn` / `QueryTypesForColumn` recover the domain with + * `C extends EncryptedV3Column<infer D>`. That inference needs `D` to appear + * BARE somewhere in the instance type. In source the only such site is + * `private readonly definition: D` — but `tsc` strips the types of private + * members on declaration emit, so the shipped `.d.ts` says + * `private readonly definition;` and the site disappears. What is left is + * `getEqlType(): D['eqlType']`, `getQueryCapabilities(): D['capabilities']` + * and `isQueryable(): QueryableFlag<D>` — all uninvertible, so `infer D` fell + * back to the `V3DomainDefinition` constraint and every helper collapsed: + * `QueryTypesForColumn` to `never`, `PlaintextForColumn` to the union of + * every domain's plaintext. Typed `encryptQuery` was therefore uncallable for + * ANY column against the published package. + * + * `declare` means type-only: no field is emitted, no runtime cost, no + * constructor change — but the declaration survives emit and gives `infer D` + * the bare site it needs. Optional so no call site has to supply it. + * + * Keyed by a non-exported `unique symbol` rather than a plain name. There is + * no `stripInternal` in this build, so `@internal` is documentation only — a + * `__domain` property would be reachable from customer code and show up in + * autocomplete on every column, typed `D | undefined` while being `undefined` + * always. The symbol is not exported, so the carrier is unnameable outside + * this module while remaining exactly as inferrable. + * + * @internal Not for consumption; read the domain via `getEqlType()` / + * `getQueryCapabilities()`. + */ + declare readonly [domainCarrier]?: D + constructor( private readonly columnName: string, private readonly definition: D, diff --git a/packages/stack/src/schema/index.ts b/packages/stack/src/schema/index.ts index 27fa88d47..a53ece0a2 100644 --- a/packages/stack/src/schema/index.ts +++ b/packages/stack/src/schema/index.ts @@ -219,6 +219,9 @@ export type EncryptConfig = z.infer<typeof encryptConfigSchema> * Builder for a nested encrypted field (encrypted but not searchable). * Create with {@link encryptedField}. Use inside nested objects in {@link encryptedTable}; * supports `.dataType()` for plaintext type. No index methods (equality, orderAndRange, etc.). + * + * @deprecated EQL v2 authoring. The client authors EQL v3; this class backs the + * v2 `encryptedField` builder, retained only for reading/migrating legacy v2 data. */ export class EncryptedField { private valueName: string @@ -263,6 +266,12 @@ export class EncryptedField { } } +/** + * Builder for an EQL v2 encrypted column. Create with {@link encryptedColumn}. + * + * @deprecated EQL v2 authoring. The client authors EQL v3; this class backs the + * v2 `encryptedColumn` builder, retained only for reading/migrating legacy v2 data. + */ export class EncryptedColumn { private columnName: string private castAsValue: CastAs @@ -602,6 +611,11 @@ export type InferEncrypted<T extends EncryptedTable<any>> = * so you can reference columns as `users.email` when calling `encrypt`, `decrypt`, * and `encryptQuery`. * + * @deprecated EQL v2 authoring. The client authors EQL v3 — build tables with + * `encryptedTable` + the `types.*` factories from `@cipherstash/stack/v3`. This + * v2 builder remains only for reading/migrating legacy v2 data and will be + * removed once the v2 adapters are gone. + * * @param tableName - The name of the database table this schema represents. * @param columns - An object whose keys are logical column names and values are * {@link EncryptedColumn} from {@link encryptedColumn}, or nested objects whose @@ -649,6 +663,11 @@ export function encryptedTable<T extends EncryptedTableColumn>( * `.searchableJson()`) and/or `.dataType()` to configure searchable encryption * and the plaintext data type. * + * @deprecated EQL v2 authoring. The client authors EQL v3 — define columns with + * the `types.*` concrete-domain factories from `@cipherstash/stack/v3`. This v2 + * builder remains only for reading/migrating legacy v2 data and will be removed + * once the v2 adapters are gone. + * * @param columnName - The name of the database column to encrypt. * @returns A new `EncryptedColumn` builder. * @@ -672,6 +691,11 @@ export function encryptedColumn(columnName: string) { * for nested fields that are encrypted but not searchable (no indexes). Use `.dataType()` * to specify the plaintext type. * + * @deprecated EQL v2 authoring. The client authors EQL v3 — model nested fields + * with a dotted v3 column path (`'profile.ssn': types.TextEq(...)`) from + * `@cipherstash/stack/v3`. This v2 builder remains only for reading/migrating + * legacy v2 data and will be removed once the v2 adapters are gone. + * * @param valueName - The name of the value field. * @returns A new `EncryptedField` builder. * diff --git a/packages/stack/src/types-public.ts b/packages/stack/src/types-public.ts index 2553d3d30..bd1ec8b8f 100644 --- a/packages/stack/src/types-public.ts +++ b/packages/stack/src/types-public.ts @@ -45,6 +45,7 @@ export type { QueryTypeName, ScalarQueryTerm, SearchTerm, + V3ClientConfig, } from '@/types' // Runtime values diff --git a/packages/stack/src/types.ts b/packages/stack/src/types.ts index c516d848e..7195c4f7c 100644 --- a/packages/stack/src/types.ts +++ b/packages/stack/src/types.ts @@ -171,6 +171,12 @@ export type ClientConfig = { strategy?: AuthStrategy /** + * @deprecated The client authors EQL v3 — an all-v3 schema set forces `3` + * automatically and yields the typed client. This field remains only to read + * or write legacy EQL v2 during migration (e.g. `eqlVersion: 2` with a v2 + * schema set), and will be removed once the v2 adapters are gone. New code + * should not set it. + * * The EQL wire version the client emits — one FFI client always emits * exactly one wire format. * @@ -197,6 +203,22 @@ export type ClientConfig = { eqlVersion?: 2 | 3 } +/** + * {@link ClientConfig} for a client that authors EQL v3 — the same options + * minus the legacy `eqlVersion: 2` escape hatch. + * + * `Encryption` accepts this (not the full `ClientConfig`) alongside an all-v3 + * schema set. Forcing v2 wire over v3 schemas THROWS at setup — v2 payloads + * cannot satisfy an `eql_v3_*` domain — so admitting `2` there typed the call + * as `TypedEncryptionClient` for a call that returns no client at all. + * Adapters that are v3-only (`@cipherstash/prisma-next`, + * `@cipherstash/stack-drizzle`) should take this type for their pass-through + * config for the same reason. + */ +export type V3ClientConfig = Omit<ClientConfig, 'eqlVersion'> & { + eqlVersion?: 3 +} + type AtLeastOneCsTable<T> = [T, ...T[]] /** Structural contract for a column builder the client can consume for STORAGE diff --git a/packages/stack/src/utils/logger/index.ts b/packages/stack/src/utils/logger/index.ts index cf84ab9ad..0d54ead7c 100644 --- a/packages/stack/src/utils/logger/index.ts +++ b/packages/stack/src/utils/logger/index.ts @@ -14,7 +14,16 @@ export type LogLevel = 'debug' | 'info' | 'error' const validLevels: readonly LogLevel[] = ['debug', 'info', 'error'] as const function levelFromEnv(): LogLevel { - const env = process.env.STASH_STACK_LOG + // `process` is absent in a Worker or Deno isolate. This module is reachable + // from `@cipherstash/stack/adapter-kit` (`src/adapter-kit.ts:60`), which the + // Supabase, Drizzle and Prisma Next adapters all value-import — an unguarded + // read here is a ReferenceError at import time on those runtimes. Guard + // `process.env` too: some partial polyfills define `process` without `env`, + // where `process.env.STASH_STACK_LOG` would throw just the same. + const env = + typeof process === 'undefined' || !process.env + ? undefined + : process.env.STASH_STACK_LOG if (env && validLevels.includes(env as LogLevel)) return env as LogLevel return 'error' } diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index 42d1d75c2..2b2d8dd6d 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -64,17 +64,24 @@ * import { OidcFederationStrategy } from "@cipherstash/stack/wasm-inline" * import { cookieStore } from "@cipherstash/auth/cookies" * - * const authStrategy = OidcFederationStrategy.create( + * // `create` returns a Result — UNWRAP it. `config.authStrategy` takes the + * // strategy itself; handing it the Result fails later and opaquely. + * const strategy = OidcFederationStrategy.create( * "crn:ap-southeast-2.aws:my-workspace-id", () => getClerkSessionToken(req), * { store: cookieStore({ request: req, responseHeaders }) }, * ) - * const client = await Encryption({ schemas, config: { authStrategy, clientId, clientKey } }) + * if (strategy.failure) throw new Error(strategy.failure.error.message) + * + * const client = await Encryption({ + * schemas, config: { authStrategy: strategy.data, clientId, clientKey }, + * }) * ``` * * For service-to-service / CI use with a custom token store, build an * `AccessKeyStrategy.create(workspaceCrn, accessKey, { store })` the same - * way (it derives the region from the CRN). Both strategies are - * re-exported from this entry. + * way — same Result-unwrapping, and it derives the region from the CRN. Both + * strategies are re-exported from this entry. An auth strategy and + * `config.accessKey` are mutually exclusive. */ import { withResult } from '@byteslice/result' @@ -655,14 +662,48 @@ const INTERNAL_CONSTRUCT = Symbol('cs-wasm-client') * ZeroKMS round trip regardless of how many fields or models it covers. What * still differs from the native surface is deliberate and local: failures come * back as this entry's `{ failure }` Results (rather than a thenable operation - * with `.audit()`), and there is no `.withLockContext()` — identity-bound - * encryption on the edge is configured at client construction via - * `config.authStrategy` instead (#663 context). + * with `.audit()`). + * + * There is also no `.withLockContext()` here, and that one is a **gap, not a + * design decision** (#797). An earlier version of this comment said + * identity-bound encryption is "configured at client construction via + * `config.authStrategy` instead" (#663 context). That is wrong, and the + * conflation is worth naming because it is the one people arrive with: + * an auth strategy decides WHO THE CLIENT IS; a lock context decides WHICH + * KEY THE VALUE IS ENCRYPTED UNDER. They are orthogonal, and only the first + * exists on this entry. + * + * The consequences are silent, which is why they are stated here rather than + * left to a failed decrypt: values written from this entry are encrypted under + * the workspace key even when the client is authenticated as an end user, and + * this entry cannot read anything the native entry wrote under a lock context, + * since decrypt requires the same context. `skills/stash-edge` documents both. + * + * The binding accepts a lock context on both paths — `lockContext` is an + * option field on the single calls and per-payload-item on the bulk ones — so + * closing this is plumbing rather than a new capability. It is not plumbed + * here yet, and shipping it would want a live round-trip test first: a wrong + * or missing claim surfaces as a failed decrypt, not a key error. * * Construct via {@link Encryption} — the constructor is private to * prevent callers from wrapping arbitrary objects in this type. */ export class WasmEncryptionClient { + /** + * This client's `decryptModel` / `bulkDecryptModels` REQUIRE the table — they + * resolve date fields from a per-table map and throw without it. The native + * clients derive the table from the payloads instead, so callers that hold a + * client structurally cannot tell the two apart. + * + * `encryptedDynamoDB` is the caller that cares: its legacy EQL v2 read path + * deliberately omits the table (a v2 table means nothing to a v3 + * reconstructor map), which reached `requireTable` with `undefined` and threw + * a TypeError about `tableName` pointing nowhere near the cause. Declared + * rather than sniffed so the check is a stated capability, not a guess about + * arity or constructor name (#772 review, finding 10). + */ + readonly requiresTableForDecrypt = true + /** @internal */ private readonly client: unknown @@ -882,7 +923,7 @@ export class WasmEncryptionClient { (term) => term.value !== null && term.value !== undefined, async (live) => // The FFI's batch field is `queries` (matching the native - // ffiEncryptQueryBulk call in packages/protect). + // ffiEncryptQueryBulk call in the encryption client). (await wasmEncryptQueryBulk( // biome-ignore lint/plugin: the FFI handle is an opaque wasm-bindgen pointer with no JS-side type this.client as never, diff --git a/packages/stack/tsconfig.json b/packages/stack/tsconfig.json index ddc4ca84c..7b8c05f90 100644 --- a/packages/stack/tsconfig.json +++ b/packages/stack/tsconfig.json @@ -58,5 +58,10 @@ // depend on `build`, so CI can run type tests against an older dist). "@cipherstash/stack/errors": ["./src/errors/index.ts"] } - } + }, + // `dist-types/` deliberately typechecks against the EMITTED declarations, so + // it has its own tsconfig with none of the `@/*` source paths above. Checking + // it here too would resolve its imports differently and report failures that + // say nothing about what ships. Run it with `pnpm test:types:dist`. + "exclude": ["dist-types"] } diff --git a/packages/stack/turbo.json b/packages/stack/turbo.json new file mode 100644 index 000000000..8649e7366 --- /dev/null +++ b/packages/stack/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "test": { + "dependsOn": ["^build", "build"] + } + } +} diff --git a/packages/test-kit/package.json b/packages/test-kit/package.json index 656ffc96c..8c4738389 100644 --- a/packages/test-kit/package.json +++ b/packages/test-kit/package.json @@ -8,6 +8,7 @@ "./integration-clerk": "./src/integration/clerk.ts", ".": "./src/index.ts", "./catalog": "./src/catalog.ts", + "./install": "./src/install.ts", "./json-suite": "./src/run-json-suite.ts", "./suite": "./src/run-family-suite.ts" }, diff --git a/packages/wizard/package.json b/packages/wizard/package.json index 467135fd3..f9d982171 100644 --- a/packages/wizard/package.json +++ b/packages/wizard/package.json @@ -26,6 +26,7 @@ "postbuild": "chmod +x ./dist/bin/wizard.js", "dev": "tsup --watch", "test": "vitest run", + "typecheck": "tsc --project tsconfig.json --noEmit", "lint": "biome check ." }, "dependencies": { diff --git a/packages/wizard/src/__tests__/post-agent.test.ts b/packages/wizard/src/__tests__/post-agent.test.ts index 0ee7fc51d..e104be5a3 100644 --- a/packages/wizard/src/__tests__/post-agent.test.ts +++ b/packages/wizard/src/__tests__/post-agent.test.ts @@ -1,11 +1,32 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runPostAgentSteps } from '../lib/post-agent.js' import type { DetectedPackageManager } from '../lib/types.js' // Mock the child_process module vi.mock('node:child_process') +// Only `confirm` is replaced — the log/spinner calls stay real so the module's +// output paths still execute. +vi.mock('@clack/prompts', async (importOriginal) => { + const actual = await importOriginal<typeof import('@clack/prompts')>() + return { ...actual, confirm: vi.fn(async () => false) } +}) + +// Wraps the REAL sweep, so every test below still exercises it for free. Only +// the empty-message case overrides it, because no real filesystem error is +// reachable with a blank `message`. +vi.mock('../lib/rewrite-migrations.js', async (importOriginal) => { + const actual = + await importOriginal<typeof import('../lib/rewrite-migrations.js')>() + return { ...actual, sweepMigrationDirs: vi.fn(actual.sweepMigrationDirs) } +}) + import * as childProcess from 'node:child_process' +import * as p from '@clack/prompts' +import { sweepMigrationDirs } from '../lib/rewrite-migrations.js' const bun: DetectedPackageManager = { name: 'bun', @@ -99,3 +120,276 @@ describe('runPostAgentSteps execution commands', () => { expect(commands).toContain('bunx stash eql install') }) }) + +// The sweep's own warning says "do NOT run the migration" on a populated table. +// Defaulting the very next prompt to Yes invites the mistake the warning exists +// to prevent — so a sweep that touched anything flips the default to No. +describe('drizzle migrate prompt after a destructive rewrite', () => { + let cwd: string + + const runDrizzle = () => + runPostAgentSteps({ + cwd, + integration: 'drizzle', + packageManager: bun, + gathered: { + installCommand: 'bun add @cipherstash/stack', + hasStashConfig: true, + usesProxy: false, + } as never, + }) + + /** + * Make `dir` a drizzle-kit OUTPUT directory. The sweep now requires the + * `meta/_journal.json` drizzle-kit maintains, because `migrations/` and + * `src/db/migrations/` are generic names other tools use and this sweep + * emits `DROP COLUMN`. + */ + const makeDrizzleOut = (dir: string): string => { + const abs = path.join(cwd, dir) + fs.mkdirSync(path.join(abs, 'meta'), { recursive: true }) + fs.writeFileSync( + path.join(abs, 'meta', '_journal.json'), + JSON.stringify({ version: '7', dialect: 'postgresql', entries: [] }), + ) + return abs + } + + beforeEach(() => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'wizard-post-agent-')) + vi.mocked(p.confirm).mockClear() + }) + + afterEach(() => { + // Every test here writes a real temp tree; without this they accumulate in + // os.tmpdir() for the life of the machine. + fs.rmSync(cwd, { recursive: true, force: true }) + }) + + it('defaults to Yes when the sweep changed nothing', async () => { + makeDrizzleOut('drizzle') + fs.writeFileSync( + path.join(cwd, 'drizzle', '0000_init.sql'), + 'CREATE TABLE "users" ("id" integer PRIMARY KEY);\n', + ) + + await runDrizzle() + + const [options] = vi.mocked(p.confirm).mock.calls.at(-1) ?? [] + expect(options?.initialValue).toBe(true) + // The clean arm is the last of a 4-way nested ternary; the negative + // assertions that guard the other arms' wording never run against this one. + // A mis-nested arm here would ship a destruction/flag/unchecked warning to a + // user with nothing to sweep, and asserting only `initialValue` would miss + // it — so pin that the clean message carries none of those phrases. + const message = String(options?.message) + expect(message).not.toContain('DESTROYS data') + expect(message).not.toContain('flagged for review') + expect(message).not.toContain('could not check') + }) + + it('defaults to No, and says why, when a file was rewritten', async () => { + makeDrizzleOut('drizzle') + // The sweep is fail-closed: it rewrites a column only when the corpus + // positively declares it (and it isn't already encrypted). A real drizzle + // corpus carries this declaration in an earlier migration — supply it so + // the fixture matches what the sweep actually requires, and the ALTER + // below is genuinely rewritten rather than skipped as source-unknown. + fs.writeFileSync( + path.join(cwd, 'drizzle', '0000_declare.sql'), + 'CREATE TABLE "users" ("email" text);\n', + ) + fs.writeFileSync( + path.join(cwd, 'drizzle', '0001_encrypt.sql'), + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + await runDrizzle() + + const [options] = vi.mocked(p.confirm).mock.calls.at(-1) ?? [] + expect(options?.initialValue).toBe(false) + expect(String(options?.message)).toContain('DESTROYS data') + + // Assert the REWRITE actually happened, not just that the prompt defaulted + // to No — both this test and its `source-unknown` sibling below produce + // `initialValue: false` and a message containing "DESTROYS data" is the + // only thing that used to distinguish them, and that came from the same + // skipped-statement branch too. Without the 0000_declare.sql fixture the + // ALTER is skipped as source-unknown rather than rewritten, so pin the + // on-disk effect a genuine rewrite leaves behind. + const swept = fs.readFileSync( + path.join(cwd, 'drizzle', '0001_encrypt.sql'), + 'utf-8', + ) + expect(swept).toContain('DROP COLUMN') + expect(swept).not.toContain('SET DATA TYPE') + }) + + it('defaults to No when a statement was flagged rather than rewritten', async () => { + makeDrizzleOut('drizzle') + fs.writeFileSync( + path.join(cwd, 'drizzle', '0001_using.sql'), + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search USING (email)::eql_v3_text_search;\n', + ) + + await runDrizzle() + + const [options] = vi.mocked(p.confirm).mock.calls.at(-1) ?? [] + expect(options?.initialValue).toBe(false) + // Nothing was rewritten here — the statement was left on disk and merely + // flagged — so the prompt must not claim data destruction the way the + // genuinely-rewritten case above does. + expect(String(options?.message)).not.toContain('DESTROYS data') + expect(String(options?.message)).toContain('flagged for review') + }) + + // A directory whose sweep threw contributes 0 to both totals, so a failed + // sweep used to be indistinguishable from a clean one: prompt defaulting to + // Yes over migrations nobody checked. Unknown is not the same as safe. + it('defaults to No when a directory could not be swept at all', async () => { + // A directory named `*.sql` makes readFile throw EISDIR mid-sweep. + makeDrizzleOut('drizzle') + fs.mkdirSync(path.join(cwd, 'drizzle', '0001_alter.sql')) + + await runDrizzle() + + const [options] = vi.mocked(p.confirm).mock.calls.at(-1) ?? [] + expect(options?.initialValue).toBe(false) + // Nothing is known about that directory, so the prompt must not claim the + // migration destroys data — only that it went unchecked. + expect(String(options?.message)).not.toContain('DESTROYS data') + expect(String(options?.message)).toContain('drizzle/') + expect(String(options?.message)).toContain('could not check 1 directory') + }) + + // `error` is built as `err instanceof Error ? err.message : String(err)`, and + // `new Error()` has an empty message — so a thrown error can arrive as `''`. + // Testing it for truthiness rather than presence would drop that directory + // back into the fail-open default, which is the exact bug above wearing a + // different hat. + it('treats an empty error message as a failed sweep, not a clean one', async () => { + vi.mocked(sweepMigrationDirs).mockResolvedValueOnce([ + { dir: 'drizzle', rewritten: [], skipped: [], error: '' }, + ]) + + await runDrizzle() + + const [options] = vi.mocked(p.confirm).mock.calls.at(-1) ?? [] + expect(options?.initialValue).toBe(false) + expect(String(options?.message)).toContain('could not check 1 directory') + }) + + // The wizard ships scanning drizzle/, migrations/ and src/db/migrations/ and + // indexes each SEPARATELY — the per-directory index is the mechanism the + // fail-closed rule exists to make safe. Every test above uses only drizzle/, + // so shrinking the shipped constant to ['drizzle'], or short-circuiting the + // aggregation loop after the first directory, leaves them all green. These two + // put the actionable content in a NON-first directory so those regressions + // fail. + it('sweeps a candidate directory other than the first', async () => { + // drizzle/ exists but has nothing to do; the rewrite lives in migrations/. + makeDrizzleOut('drizzle') + fs.writeFileSync( + path.join(cwd, 'drizzle', '0000_init.sql'), + 'CREATE TABLE "widgets" ("id" integer PRIMARY KEY);\n', + ) + makeDrizzleOut('migrations') + fs.writeFileSync( + path.join(cwd, 'migrations', '0000_declare.sql'), + 'CREATE TABLE "users" ("email" text);\n', + ) + fs.writeFileSync( + path.join(cwd, 'migrations', '0001_encrypt.sql'), + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + await runDrizzle() + + // The migrations/ ALTER was rewritten — proof the second directory was + // swept, not just drizzle/. + const swept = fs.readFileSync( + path.join(cwd, 'migrations', '0001_encrypt.sql'), + 'utf-8', + ) + expect(swept).toContain('DROP COLUMN') + expect(swept).not.toContain('SET DATA TYPE') + const [options] = vi.mocked(p.confirm).mock.calls.at(-1) ?? [] + expect(options?.initialValue).toBe(false) + expect(String(options?.message)).toContain('DESTROYS data') + }) + + // The other half of the test above. `migrations/` is swept when it is a + // drizzle output directory — and must NOT be when it belongs to Knex, + // node-pg-migrate, Flyway or hand-rolled psql, all of which use that name. + // The wizard was never pointed at such a directory, and this sweep emits + // DROP COLUMN (#772 review, finding 5). + it('leaves a migrations/ directory that is not a drizzle output untouched', async () => { + makeDrizzleOut('drizzle') + fs.writeFileSync( + path.join(cwd, 'drizzle', '0000_init.sql'), + 'CREATE TABLE "widgets" ("id" integer PRIMARY KEY);\n', + ) + // A foreign migration history: self-contained, so the fail-closed + // `declared` rule would happily pass it. + fs.mkdirSync(path.join(cwd, 'migrations'), { recursive: true }) + const foreign = path.join(cwd, 'migrations', '002_encrypt.sql') + const foreignSql = [ + 'CREATE TABLE "patients" ("ssn" text);', + 'ALTER TABLE "patients" ALTER COLUMN "ssn" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\n') + fs.writeFileSync(foreign, foreignSql) + + // Only `confirm` is mocked at module level; spy on the log so the + // "passed over" notice can be asserted. + const info = vi.spyOn(p.log, 'info').mockImplementation(() => {}) + + await runDrizzle() + + expect(fs.readFileSync(foreign, 'utf-8')).toBe(foreignSql) + expect(fs.readFileSync(foreign, 'utf-8')).not.toContain('DROP COLUMN') + // Nothing was rewritten or flagged, so the prompt stays on its clean arm. + const [options] = vi.mocked(p.confirm).mock.calls.at(-1) ?? [] + expect(options?.initialValue).toBe(true) + // But the user is told the directory was passed over, so a genuine drizzle + // output whose meta/ went missing does not just look clean. + const logged = info.mock.calls.flat().join('\n') + expect(logged).toContain('migrations/') + expect(logged).toContain('meta/_journal.json') + info.mockRestore() + }) + + it('aggregates a rewrite in one directory with a flag in another', async () => { + // drizzle/ declares email, so its ALTER is rewritten. + makeDrizzleOut('drizzle') + fs.writeFileSync( + path.join(cwd, 'drizzle', '0000_declare.sql'), + 'CREATE TABLE "users" ("email" text);\n', + ) + fs.writeFileSync( + path.join(cwd, 'drizzle', '0001_encrypt.sql'), + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + // migrations/ never declares its column, so its ALTER is source-unknown. + makeDrizzleOut('migrations') + const flagged = path.join(cwd, 'migrations', '0001_encrypt.sql') + const flaggedSql = + 'ALTER TABLE "orders" ALTER COLUMN "total" SET DATA TYPE eql_v3_text_search;\n' + fs.writeFileSync(flagged, flaggedSql) + + await runDrizzle() + + // drizzle/ was rewritten... + const rewritten = fs.readFileSync( + path.join(cwd, 'drizzle', '0001_encrypt.sql'), + 'utf-8', + ) + expect(rewritten).toContain('DROP COLUMN') + // ...and migrations/ was left untouched, flagged rather than rewritten. + expect(fs.readFileSync(flagged, 'utf-8')).toBe(flaggedSql) + // A rewrite anywhere makes the prompt destructive and defaults it to No. + const [options] = vi.mocked(p.confirm).mock.calls.at(-1) ?? [] + expect(options?.initialValue).toBe(false) + expect(String(options?.message)).toContain('DESTROYS data') + }) +}) diff --git a/packages/wizard/src/__tests__/rewrite-migrations.test.ts b/packages/wizard/src/__tests__/rewrite-migrations.test.ts new file mode 100644 index 000000000..1914febcf --- /dev/null +++ b/packages/wizard/src/__tests__/rewrite-migrations.test.ts @@ -0,0 +1,1935 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + describeSkipReason, + rewriteEncryptedAlterColumns, + sweepMigrationDirs, +} from '../lib/rewrite-migrations.js' + +describe('rewriteEncryptedAlterColumns', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wizard-rewrite-')) + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + /** + * Declare `columns` on `tableRef` as PLAINTEXT, in a migration that sorts + * before every fixture below. + * + * The sweep is fail-closed: it rewrites a column only when the corpus shows + * the column exists and is not already encrypted. A fixture that is just an + * ALTER declares nothing, so it is `source-unknown` by design — a test that + * exercises the REWRITE has to supply the `CREATE TABLE` a real drizzle + * corpus would carry. + * + * `tableRef` is written exactly as it appears in the ALTER, so a pgSchema() + * table passes `'"app"."users"'` and the declaration lands on the same key. + */ + const declarePlaintext = (tableRef: string, ...columns: string[]): void => { + const file = path.join(tmpDir, '0000_declare.sql') + const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '' + const defs = columns.map((column) => `"${column}" text`).join(', ') + fs.writeFileSync(file, `${existing}CREATE TABLE ${tableRef} (${defs});\n`) + } + + it('rewrites an in-place ALTER COLUMN with the bare v2 type name', async () => { + declarePlaintext('"transactions"', 'amount') + const original = `ALTER TABLE "transactions" ALTER COLUMN "amount" SET DATA TYPE eql_v2_encrypted;\n` + const filePath = path.join(tmpDir, '0002_alter.sql') + fs.writeFileSync(filePath, original) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([filePath]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + 'ALTER TABLE "transactions" ADD COLUMN "amount__cipherstash_tmp" "public"."eql_v2_encrypted";', + ) + expect(updated).toContain( + 'ALTER TABLE "transactions" DROP COLUMN "amount";', + ) + expect(updated).toContain( + 'ALTER TABLE "transactions" RENAME COLUMN "amount__cipherstash_tmp" TO "amount";', + ) + expect(updated).not.toContain('SET DATA TYPE') + // Wizard-branded header. + expect(updated).toContain('-- Rewritten by @cipherstash/wizard') + }) + + it('rewrites a bare v3 domain (the generation the wizard now scaffolds)', async () => { + declarePlaintext('"users"', 'email') + const original = `ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n` + const filePath = path.join(tmpDir, '0002_v3.sql') + fs.writeFileSync(filePath, original) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([filePath]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + 'ALTER TABLE "users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v3_text_search";', + ) + expect(updated).not.toContain('SET DATA TYPE') + }) + + it('rewrites the schema-qualified form produced by drizzle-kit', async () => { + declarePlaintext('"users"', 'email') + const original = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE "public"."eql_v3_text_search";\n' + const filePath = path.join(tmpDir, '0003_alter.sql') + fs.writeFileSync(filePath, original) + + await rewriteEncryptedAlterColumns(tmpDir) + + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + 'ALTER TABLE "users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v3_text_search";', + ) + expect(updated).not.toContain('SET DATA TYPE') + }) + + it('rewrites a schema-qualified table produced by pgSchema()', async () => { + // drizzle-kit emits `"app"."users"` for a table declared in a pgSchema(); + // the old `\s+` between the table and ALTER COLUMN could never cross the `.`. + declarePlaintext('"app"."users"', 'email') + const original = + 'ALTER TABLE "app"."users" ALTER COLUMN "email" SET DATA TYPE "undefined"."eql_v3_text_search";\n' + const filePath = path.join(tmpDir, '0014_qualified.sql') + fs.writeFileSync(filePath, original) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([filePath]) + const updated = fs.readFileSync(filePath, 'utf-8') + // Every emitted statement keeps the schema qualifier. + expect(updated).toContain( + 'ALTER TABLE "app"."users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v3_text_search";', + ) + expect(updated).toContain('ALTER TABLE "app"."users" DROP COLUMN "email";') + expect(updated).toContain( + 'ALTER TABLE "app"."users" RENAME COLUMN "email__cipherstash_tmp" TO "email";', + ) + expect(updated).not.toContain('SET DATA TYPE') + }) + + it('rewrites the "undefined" schema form drizzle-kit emits for bare custom types', async () => { + declarePlaintext('"transactions"', 'amount') + const original = + 'ALTER TABLE "transactions" ALTER COLUMN "amount" SET DATA TYPE "undefined"."eql_v2_encrypted";\n' + const filePath = path.join(tmpDir, '0005_undef.sql') + fs.writeFileSync(filePath, original) + + await rewriteEncryptedAlterColumns(tmpDir) + + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + 'ALTER TABLE "transactions" ADD COLUMN "amount__cipherstash_tmp" "public"."eql_v2_encrypted";', + ) + expect(updated).not.toContain('SET DATA TYPE') + }) + + it('rewrites the double-quoted form produced by stack 0.15.0', async () => { + declarePlaintext('"transactions"', 'description') + const original = + 'ALTER TABLE "transactions" ALTER COLUMN "description" SET DATA TYPE "undefined".""public"."eql_v2_encrypted"";\n' + const filePath = path.join(tmpDir, '0006_double.sql') + fs.writeFileSync(filePath, original) + + await rewriteEncryptedAlterColumns(tmpDir) + + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + 'ALTER TABLE "transactions" ADD COLUMN "description__cipherstash_tmp" "public"."eql_v2_encrypted";', + ) + expect(updated).not.toContain('SET DATA TYPE') + }) + + it('leaves unrelated migrations untouched', async () => { + const original = + 'CREATE TABLE "widgets" ("id" integer PRIMARY KEY, "name" text);\n' + const filePath = path.join(tmpDir, '0001_init.sql') + fs.writeFileSync(filePath, original) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(fs.readFileSync(filePath, 'utf-8')).toBe(original) + }) + + it('skips the file passed in options.skip', async () => { + declarePlaintext('"t"', 'c') + const install = path.join(tmpDir, '0000_install-eql.sql') + const alter = path.join(tmpDir, '0002_alter.sql') + fs.writeFileSync(install, 'CREATE SCHEMA eql_v2;\n') + fs.writeFileSync( + alter, + 'ALTER TABLE "t" ALTER COLUMN "c" SET DATA TYPE eql_v2_encrypted;', + ) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir, { + skip: install, + }) + expect(rewritten).toEqual([alter]) + expect(fs.readFileSync(install, 'utf-8')).toBe('CREATE SCHEMA eql_v2;\n') + }) + + it('returns an empty result when the directory does not exist', async () => { + const missing = path.join(tmpDir, 'does-not-exist') + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(missing) + expect(rewritten).toEqual([]) + expect(skipped).toEqual([]) + }) + + // Every concrete `eql_v3_*` domain shipped by `@cipherstash/stack/eql/v3` + // (see `packages/stack/src/eql/v3/columns.ts`). Eight scalar bases carry the + // four storage/eq/ord flavours; text adds `_match`/`_search`; boolean and json + // stand alone. + const V3_SCALAR_BASES = [ + 'integer', + 'smallint', + 'bigint', + 'numeric', + 'real', + 'double', + 'date', + 'timestamp', + ] + const V3_DOMAINS = [ + ...V3_SCALAR_BASES.flatMap((base) => + ['', '_eq', '_ord', '_ord_ore'].map( + (flavour) => `eql_v3_${base}${flavour}`, + ), + ), + 'eql_v3_text', + 'eql_v3_text_eq', + 'eql_v3_text_match', + 'eql_v3_text_ord', + 'eql_v3_text_ord_ore', + 'eql_v3_text_search', + 'eql_v3_boolean', + 'eql_v3_json', + ] + + it.each(V3_DOMAINS)('rewrites an ALTER COLUMN to %s', async (domain) => { + declarePlaintext('"users"', 'email') + const filePath = path.join(tmpDir, '0007_v3.sql') + fs.writeFileSync( + filePath, + `ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE "undefined"."${domain}";\n`, + ) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([filePath]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + `ALTER TABLE "users" ADD COLUMN "email__cipherstash_tmp" "public"."${domain}";`, + ) + expect(updated).toContain('ALTER TABLE "users" DROP COLUMN "email";') + expect(updated).toContain( + 'ALTER TABLE "users" RENAME COLUMN "email__cipherstash_tmp" TO "email";', + ) + expect(updated).not.toContain('SET DATA TYPE') + }) + + // DOMAIN_RE is derived from ENCRYPTED_DOMAIN, so drift between the two can't + // silently leave a domain unrewritten. Prove every domain the alternation + // recognises is actually extracted into the emitted ADD COLUMN. + it.each([ + ...V3_DOMAINS, + 'eql_v2_encrypted', + ])('extracts the bare domain %s from a mangled ALTER', async (domain) => { + declarePlaintext('"t"', 'c') + const filePath = path.join(tmpDir, '0015_drift.sql') + fs.writeFileSync( + filePath, + `ALTER TABLE "t" ALTER COLUMN "c" SET DATA TYPE "undefined"."${domain}";\n`, + ) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([filePath]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + `ALTER TABLE "t" ADD COLUMN "c__cipherstash_tmp" "public"."${domain}";`, + ) + expect(updated).not.toContain('SET DATA TYPE') + }) + + // The mangled forms are the cross product of what `dataType()` returns and + // which drizzle-kit era renders it (see the file's comment table). + const MANGLED_FORMS: Array<[label: string, emitted: string]> = [ + ['plain, drizzle-kit <=0.30.6', 'eql_v3_text_search'], + [ + '"undefined"-prefixed, drizzle-kit >=0.31.0', + '"undefined"."eql_v3_text_search"', + ], + ['dotted, drizzle-kit <=0.30.6', 'public.eql_v3_text_search'], + [ + 'dotted inside "undefined", drizzle-kit >=0.31.0', + '"undefined"."public.eql_v3_text_search"', + ], + ['pre-quoted, drizzle-kit <=0.30.6', '"public"."eql_v3_text_search"'], + [ + 'pre-quoted inside "undefined", drizzle-kit >=0.31.0', + '"undefined".""public"."eql_v3_text_search""', + ], + ['bare-quoted (speculative)', '"eql_v3_text_search"'], + ] + + it.each(MANGLED_FORMS)('rewrites the v3 %s form', async (_label, emitted) => { + declarePlaintext('"users"', 'email') + const filePath = path.join(tmpDir, '0008_form.sql') + fs.writeFileSync( + filePath, + `ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE ${emitted};\n`, + ) + + await rewriteEncryptedAlterColumns(tmpDir) + + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + 'ALTER TABLE "users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v3_text_search";', + ) + expect(updated).not.toContain('SET DATA TYPE') + }) + + it('names the target domain in the guidance comment', async () => { + declarePlaintext('"users"', 'email') + const filePath = path.join(tmpDir, '0010_comment.sql') + fs.writeFileSync( + filePath, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE "undefined"."eql_v3_integer_ord";\n', + ) + + await rewriteEncryptedAlterColumns(tmpDir) + + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain('-- eql_v3_integer_ord.') + }) + + it('warns that the rewrite is data-destroying / empty-table-only', async () => { + declarePlaintext('"users"', 'email') + const filePath = path.join(tmpDir, '0016_warn.sql') + fs.writeFileSync( + filePath, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + await rewriteEncryptedAlterColumns(tmpDir) + + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain('safe ONLY if') + expect(updated).toContain('constraints, defaults, and indexes') + expect(updated).toContain('stash encrypt') + }) + + it('separates ADD/DROP/RENAME with --> statement-breakpoint', async () => { + declarePlaintext('"users"', 'email') + const filePath = path.join(tmpDir, '0018_breakpoint.sql') + fs.writeFileSync( + filePath, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + await rewriteEncryptedAlterColumns(tmpDir) + + const updated = fs.readFileSync(filePath, 'utf-8').trimEnd() + const chunks = updated.split('--> statement-breakpoint') + expect(chunks).toHaveLength(3) + expect(chunks[0]).toContain('ADD COLUMN') + expect(chunks[1]).toContain('DROP COLUMN') + expect(chunks[2]).toContain('RENAME COLUMN') + }) + + it('rewrites each statement to its own domain when v2 and v3 are mixed', async () => { + declarePlaintext('"a"', 'x', 'y') + const filePath = path.join(tmpDir, '0011_mixed.sql') + fs.writeFileSync( + filePath, + [ + 'ALTER TABLE "a" ALTER COLUMN "x" SET DATA TYPE eql_v2_encrypted;', + 'ALTER TABLE "a" ALTER COLUMN "y" SET DATA TYPE "undefined"."eql_v3_json";', + ].join('\n'), + ) + + await rewriteEncryptedAlterColumns(tmpDir) + + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + 'ALTER TABLE "a" ADD COLUMN "x__cipherstash_tmp" "public"."eql_v2_encrypted";', + ) + expect(updated).toContain( + 'ALTER TABLE "a" ADD COLUMN "y__cipherstash_tmp" "public"."eql_v3_json";', + ) + }) + + it.each([ + ['a plaintext type', 'text'], + ['jsonb', 'jsonb'], + ['a lookalike from another EQL major', 'eql_v4_text_search'], + ['a lookalike prefix', 'not_eql_v3_text_search'], + ])('leaves an ALTER COLUMN to %s untouched', async (_label, type) => { + const original = `ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE ${type};\n` + const filePath = path.join(tmpDir, '0012_other.sql') + fs.writeFileSync(filePath, original) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(fs.readFileSync(filePath, 'utf-8')).toBe(original) + }) + + it('leaves a hand-authored SET DATA TYPE ... USING conversion untouched but flags it', async () => { + const original = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search USING (email)::eql_v3_text_search;\n' + const filePath = path.join(tmpDir, '0013_using.sql') + fs.writeFileSync(filePath, original) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toHaveLength(1) + expect(skipped[0].file).toBe(filePath) + expect(skipped[0].statement).toContain('SET DATA TYPE') + expect(skipped[0].statement).toContain('eql_v3_text_search') + // Left untouched on disk — we flag, we don't guess. + expect(fs.readFileSync(filePath, 'utf-8')).toBe(original) + }) + + it('reports no skipped statements when the strict rewrite fully handled the file', async () => { + declarePlaintext('"users"', 'email') + const filePath = path.join(tmpDir, '0021_handled.sql') + fs.writeFileSync( + filePath, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([filePath]) + expect(skipped).toEqual([]) + }) + + // A near-miss is quoted back to the user verbatim, so it must read as the + // offending statement alone. NEAR_MISS_RE opens with a lazy `[^;]*?`, which + // can only be bounded by the previous `;` — so without an explicit trim the + // reported "statement" drags in every comment and blank line since then. + it('reports a near-miss without the file-leading comment block', async () => { + const statement = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search USING (email)::eql_v3_text_search;' + const original = [ + '-- Custom SQL migration file, put your code below! --', + '-- Hand-converts the email column in place.', + '', + statement, + '', + ].join('\n') + const filePath = path.join(tmpDir, '0022_preamble.sql') + fs.writeFileSync(filePath, original) + + const { skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(skipped).toHaveLength(1) + expect(skipped[0].statement).toBe(statement) + }) + + // A statement the STRICT matcher already matched but skipped (here: + // source-unknown) is left on disk unchanged, so it still contains + // `SET DATA TYPE` and the broad near-miss scan finds it again. Before the + // preamble regex stripped a leading block comment, that second pass reported + // a DIFFERENT statement string (comment glued to the front) than the strict + // pass's `match.trim()`, so the dedup key never matched and the same + // statement came back twice: once correctly as `source-unknown`, once as + // `unrecognised-form` — contradictory advice (look for a hand-authored + // `USING` clause) for a statement that has none. + // + // The block comment must NOT sit at the very start of the file — that is + // the one shape a previous, narrower version of the preamble regex happened + // to handle. A realistic migration file has a preceding statement, so + // NEAR_MISS_RE's match starts at THAT statement's `;`, dragging the newline + // before the comment in too; a regex that only strips a comment anchored to + // the very start of the match fails here. + it('reports a block-comment-prefixed statement once, not twice', async () => { + const alterSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const filePath = path.join(tmpDir, '0040_block-preamble.sql') + fs.writeFileSync( + filePath, + [ + 'CREATE TABLE "users" ("id" integer PRIMARY KEY);', + '/* note */', + alterSql, + '', + ].join('\n'), + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([ + { file: filePath, statement: alterSql, reason: 'source-unknown' }, + ]) + }) + + // Same bug, but the comment sits on the ALTER's own line rather than its + // own — the preceding statement's `;` still starts the near-miss match + // before the (indented) comment, not at it. + it('reports an indented block-comment-prefixed statement once, not twice', async () => { + const alterSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const filePath = path.join(tmpDir, '0041_block-preamble-indented.sql') + fs.writeFileSync( + filePath, + [ + 'CREATE TABLE "users" ("id" integer PRIMARY KEY);', + ` /* note */ ${alterSql}`, + '', + ].join('\n'), + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([ + { file: filePath, statement: alterSql, reason: 'source-unknown' }, + ]) + }) + + // Same bug again, this time on the OTHER correct reason a near-miss can + // carry: the column is already encrypted, not merely undeclared. + it('reports a block-comment-prefixed statement once, not twice, for an already-encrypted column', async () => { + const alterSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const filePath = path.join(tmpDir, '0042_block-preamble-encrypted.sql') + fs.writeFileSync( + filePath, + [ + 'CREATE TABLE "users" ("id" integer PRIMARY KEY, "email" "public"."eql_v3_text_eq");', + '/* note */', + alterSql, + '', + ].join('\n'), + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([ + { file: filePath, statement: alterSql, reason: 'already-encrypted' }, + ]) + }) + + it('reports a near-miss without a preceding statement-breakpoint marker', async () => { + const statement = + 'ALTER TABLE "users" ALTER COLUMN "meta" SET DATA TYPE eql_v3_json USING (meta)::jsonb;' + const original = [ + 'CREATE TABLE "users" ("id" integer PRIMARY KEY);', + '--> statement-breakpoint', + statement, + '', + ].join('\n') + const filePath = path.join(tmpDir, '0023_breakpoint-preamble.sql') + fs.writeFileSync(filePath, original) + + const { skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(skipped).toHaveLength(1) + expect(skipped[0].statement).toBe(statement) + }) + + it('keeps a multi-line near-miss statement intact after the preamble trim', async () => { + const statement = [ + 'ALTER TABLE "users"', + ' ALTER COLUMN "email"', + ' SET DATA TYPE eql_v3_text_search USING (email)::eql_v3_text_search;', + ].join('\n') + const filePath = path.join(tmpDir, '0024_multiline.sql') + fs.writeFileSync(filePath, `-- leading note\n\n${statement}\n`) + + const { skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(skipped).toHaveLength(1) + expect(skipped[0].statement).toBe(statement) + }) + + // A multi-line replacement inherits the author's `-- ` on line 1 ONLY, so + // rewriting a commented-out ALTER turns lines 2+ — including DROP COLUMN — + // into live SQL. Commented SQL never runs; leave it exactly as written. + describe('commented-out statements', () => { + it.each([ + ['a line comment', '-- '], + ['an indented line comment', ' -- '], + ['a drizzle statement-breakpoint style prefix', '--> '], + ])('leaves an ALTER behind %s untouched', async (_label, prefix) => { + const original = `${prefix}ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n` + const filePath = path.join(tmpDir, '0030_commented.sql') + fs.writeFileSync(filePath, original) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toBe(original) + expect(updated).not.toContain('DROP COLUMN') + }) + + it('leaves an ALTER inside a block comment untouched', async () => { + const original = [ + '/* superseded by 0031', + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + '*/', + '', + ].join('\n') + const filePath = path.join(tmpDir, '0030_block.sql') + fs.writeFileSync(filePath, original) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([]) + expect(fs.readFileSync(filePath, 'utf-8')).toBe(original) + }) + + it('leaves an ALTER inside a NESTED block comment untouched', async () => { + const original = [ + '/* outer /* inner */', + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + '*/', + '', + ].join('\n') + const filePath = path.join(tmpDir, '0030_nested.sql') + fs.writeFileSync(filePath, original) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([]) + expect(fs.readFileSync(filePath, 'utf-8')).toBe(original) + }) + + it('does not report a commented-out near-miss', async () => { + const filePath = path.join(tmpDir, '0030_commented-using.sql') + fs.writeFileSync( + filePath, + '-- ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search USING (email)::eql_v3_text_search;\n', + ) + + const { skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(skipped).toEqual([]) + }) + + // The comment scan must not be fooled by `--` inside a string literal, or + // it would skip a live statement and leave broken SQL to fail at migrate. + it('still rewrites an ALTER that follows a "--" inside a string literal', async () => { + declarePlaintext('"users"', 'email') + const filePath = path.join(tmpDir, '0030_literal.sql') + fs.writeFileSync( + filePath, + [ + `INSERT INTO "notes" ("body") VALUES ('a -- b');`, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\n'), + ) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([filePath]) + expect(fs.readFileSync(filePath, 'utf-8')).toContain( + 'ALTER TABLE "users" DROP COLUMN "email";', + ) + }) + + // An apostrophe inside a DOUBLE-QUOTED identifier is not a string + // delimiter. Reading it as one opens a phantom literal whose "closing" + // quote is the apostrophe in the SAME identifier further down the file — + // PAST the commented-out ALTER — so the scan concludes the ALTER is live + // and rewrites it into a real DROP COLUMN. The CREATE that declared the + // column always sits above the ALTER, so a real corpus produces exactly + // this shape. + it('leaves a commented-out ALTER untouched when an earlier identifier holds an apostrophe', async () => { + const original = [ + 'CREATE TABLE "users" (', + '\t"id" serial PRIMARY KEY NOT NULL,', + '\t"o\'brien_data" text', + ');', + '--> statement-breakpoint', + '-- ALTER TABLE "users" ALTER COLUMN "o\'brien_data" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\n') + const filePath = path.join(tmpDir, '0031_apostrophe.sql') + fs.writeFileSync(filePath, original) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toBe(original) + expect(updated).not.toContain('DROP COLUMN') + }) + + // A statement inside a single-quoted literal is DATA, not SQL. Rewriting it + // splices `--> statement-breakpoint` markers INSIDE the literal, so + // splitting the file the way drizzle's migrator does yields a bare, live + // `ALTER TABLE ... DROP COLUMN ...;` as a chunk of its own. + it('leaves an ALTER inside a string literal untouched', async () => { + const original = [ + `INSERT INTO "audit_log" ("note") VALUES ('the reverted migration read:`, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + `(do not run it again)');`, + '', + ].join('\n') + const filePath = path.join(tmpDir, '0032_string-literal.sql') + fs.writeFileSync(filePath, original) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toBe(original) + expect(updated).not.toContain('DROP COLUMN') + expect(updated).not.toContain('--> statement-breakpoint') + }) + + // An UNTERMINATED quoted identifier must fail the same way an unterminated + // string literal does — by swallowing the rest of the file as inert. If it + // instead runs the scan cursor to the end, the loop exits and every + // commented-out ALTER below it is reported live and rewritten: the same + // destructive outcome as the apostrophe case above, one branch over. + it('leaves a commented-out ALTER untouched after an unterminated quoted identifier', async () => { + const original = [ + 'CREATE TABLE "users" ("id" serial PRIMARY KEY NOT NULL, "email" text);', + '--> statement-breakpoint', + 'SELECT "unclosed FROM users;', + '--> statement-breakpoint', + '-- ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\n') + const filePath = path.join(tmpDir, '0033_unterminated-identifier.sql') + fs.writeFileSync(filePath, original) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toBe(original) + expect(updated).not.toContain('DROP COLUMN') + }) + + // Regression pin, not a bug fix — this already behaves. A commented-out + // ALTER in a CRLF file must come back byte-identical. + it('leaves a commented-out ALTER with CRLF line endings byte-identical', async () => { + const original = [ + 'CREATE TABLE "users" ("id" integer PRIMARY KEY);', + '--> statement-breakpoint', + '-- ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\r\n') + const filePath = path.join(tmpDir, '0033_crlf.sql') + fs.writeFileSync(filePath, original) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([]) + expect(fs.readFileSync(filePath, 'utf-8')).toBe(original) + }) + + // #772 review, finding 1. Quote parity is a whole-file property: anything + // that makes the scanner disagree with Postgres about where a literal ENDS + // shifts every following token, so a commented-out ALTER downstream reads + // as live and is rewritten into a live DROP COLUMN. + // + // The two shapes below both do that. A `$$ … $$` body was documented as + // safe on the grounds that mis-reading one can only make us SKIP — that + // holds for an unterminated literal, but an odd apostrophe count inside the + // body ends a literal EARLY, which is the opposite direction. + it('leaves a commented-out ALTER alone after a dollar-quoted body with an odd apostrophe count', async () => { + declarePlaintext('"users"', 'email') + const original = [ + "CREATE FUNCTION note() RETURNS text AS $$ don't $$ LANGUAGE sql;", + '--> statement-breakpoint', + '-- it\'s ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\n') + const filePath = path.join(tmpDir, '0033_dollar-quoted.sql') + fs.writeFileSync(filePath, original) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toBe(original) + expect(updated).not.toContain('DROP COLUMN') + }) + + it('leaves a commented-out ALTER alone after a tagged dollar-quoted body', async () => { + declarePlaintext('"users"', 'email') + const original = [ + "CREATE FUNCTION note() RETURNS text AS $fn$ don't $fn$ LANGUAGE sql;", + '--> statement-breakpoint', + '-- it\'s ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\n') + const filePath = path.join(tmpDir, '0033_tagged-dollar.sql') + fs.writeFileSync(filePath, original) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([]) + expect(fs.readFileSync(filePath, 'utf-8')).toBe(original) + }) + + // A backslash-escaped quote inside an E'' string does not close it. Reading + // it as a close shifts parity for the rest of the file the same way. + it('leaves a commented-out ALTER alone after an E-string with a backslash-escaped quote', async () => { + declarePlaintext('"users"', 'email') + const original = [ + 'CREATE TABLE "notes" ("body" text);', + '--> statement-breakpoint', + "INSERT INTO \"notes\" VALUES (E'a\\'b');", + '--> statement-breakpoint', + '-- it\'s ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\n') + const filePath = path.join(tmpDir, '0033_estring.sql') + fs.writeFileSync(filePath, original) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toBe(original) + expect(updated).not.toContain('DROP COLUMN') + }) + + // The dollar-quote skip must not swallow live SQL: a `$$` body sitting + // BEFORE a genuine plaintext ALTER still leaves that ALTER rewritable. + it('still rewrites a live ALTER that follows a dollar-quoted body', async () => { + declarePlaintext('"users"', 'email') + const filePath = path.join(tmpDir, '0033_dollar-then-live.sql') + fs.writeFileSync( + filePath, + [ + "CREATE FUNCTION note() RETURNS text AS $$ don't $$ LANGUAGE sql;", + '--> statement-breakpoint', + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\n'), + ) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([filePath]) + expect(fs.readFileSync(filePath, 'utf-8')).toContain('DROP COLUMN') + }) + }) + + // ADD+DROP+RENAME on a column that is ALREADY encrypted drops CIPHERTEXT, and + // unlike the plaintext case there is nothing left anywhere to backfill from. + describe('columns that are already encrypted', () => { + it('refuses to rewrite a domain change on a column created encrypted', async () => { + const create = path.join(tmpDir, '0000_create.sql') + fs.writeFileSync( + create, + [ + 'CREATE TABLE "users" (', + '\t"id" integer PRIMARY KEY,', + '\t"email" "public"."eql_v3_text_eq"', + ');', + '', + ].join('\n'), + ) + const alterSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const alter = path.join(tmpDir, '0001_domain-change.sql') + fs.writeFileSync(alter, `${alterSql}\n`) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(fs.readFileSync(alter, 'utf-8')).toBe(`${alterSql}\n`) + expect(skipped).toEqual([ + { file: alter, statement: alterSql, reason: 'already-encrypted' }, + ]) + }) + + it('refuses to rewrite a domain change on a column ADDed encrypted', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_add.sql'), + 'ALTER TABLE "users" ADD COLUMN "email" eql_v3_text_eq;\n', + ) + const alter = path.join(tmpDir, '0001_domain-change.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toHaveLength(1) + expect(skipped[0].reason).toBe('already-encrypted') + }) + + // An EQL domain installed into a NON-`public` schema is still ciphertext. + // The mangled forms special-case the literal `public`, so without the + // extra alternative this column falls to the plaintext residue and the + // ALTER drops a column full of ciphertext. + it('refuses to rewrite a domain change on a column encrypted in a non-public schema', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + [ + 'CREATE TABLE "users" (', + '\t"id" integer PRIMARY KEY,', + '\t"email" "app"."eql_v3_text_eq"', + ');', + '', + ].join('\n'), + ) + const alter = path.join(tmpDir, '0001_domain-change.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toHaveLength(1) + expect(skipped[0].reason).toBe('already-encrypted') + }) + + // An ARRAY of an EQL domain is still ciphertext. The trailing delimiter + // lookahead must admit `[` or the column falls to the plaintext residue. + it('refuses to rewrite a domain change on a column encrypted as a domain array', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_add.sql'), + 'ALTER TABLE "users" ADD COLUMN "email" public.eql_v3_text_eq[];\n', + ) + const alter = path.join(tmpDir, '0001_domain-change.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toHaveLength(1) + expect(skipped[0].reason).toBe('already-encrypted') + }) + + // A previous sweep of this directory leaves ADD tmp + RENAME behind. The + // column it renamed onto is encrypted, so a later domain change on it is + // just as destructive. + it('follows a RENAME from a previous sweep', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_swept.sql'), + [ + 'ALTER TABLE "users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v3_text_eq";', + '--> statement-breakpoint', + 'ALTER TABLE "users" DROP COLUMN "email";', + '--> statement-breakpoint', + 'ALTER TABLE "users" RENAME COLUMN "email__cipherstash_tmp" TO "email";', + '', + ].join('\n'), + ) + const alter = path.join(tmpDir, '0001_domain-change.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toHaveLength(1) + expect(skipped[0].reason).toBe('already-encrypted') + }) + + it('reports the destructive statement once, not twice', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_add.sql'), + 'ALTER TABLE "users" ADD COLUMN "email" eql_v3_text_eq;\n', + ) + fs.writeFileSync( + path.join(tmpDir, '0001_domain-change.sql'), + [ + '-- Custom SQL migration file, put your code below! --', + '', + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\n'), + ) + + const { skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(skipped).toHaveLength(1) + expect(skipped[0].reason).toBe('already-encrypted') + }) + + // The scoping matters: encrypting `contacts.email` must not be blocked by + // an unrelated `users.email` that happens to share a column name. + it('scopes the check to the table', async () => { + declarePlaintext('"contacts"', 'email') + fs.writeFileSync( + path.join(tmpDir, '0000_add.sql'), + 'ALTER TABLE "users" ADD COLUMN "email" eql_v3_text_eq;\n', + ) + const alter = path.join(tmpDir, '0001_contacts.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "contacts" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([alter]) + expect(skipped).toEqual([]) + }) + + // The ordinary case this rewrite exists for: plaintext today, encrypted + // after the ALTER. Nothing to preserve, so rewrite it. + it('still rewrites a plaintext column created in an earlier migration', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + 'CREATE TABLE "users" (\n\t"id" integer PRIMARY KEY,\n\t"email" text\n);\n', + ) + const alter = path.join(tmpDir, '0001_encrypt.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([alter]) + expect(skipped).toEqual([]) + }) + + // A commented-out ADD never ran, so it says nothing about the live schema. + it('ignores an encrypted ADD COLUMN that is commented out', async () => { + declarePlaintext('"users"', 'email') + fs.writeFileSync( + path.join(tmpDir, '0000_add.sql'), + '-- ALTER TABLE "users" ADD COLUMN "email" eql_v3_text_eq;\n', + ) + const alter = path.join(tmpDir, '0001_encrypt.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([alter]) + }) + + // `options.skip` excludes a file from being EDITED, not from describing the + // schema the other files are altering. + it('honours an encrypted column defined in the skipped file', async () => { + const skipPath = path.join(tmpDir, '0000_install.sql') + fs.writeFileSync( + skipPath, + 'ALTER TABLE "users" ADD COLUMN "email" eql_v3_text_eq;\n', + ) + fs.writeFileSync( + path.join(tmpDir, '0001_domain-change.sql'), + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns( + tmpDir, + { + skip: skipPath, + }, + ) + + expect(rewritten).toEqual([]) + expect(skipped[0]?.reason).toBe('already-encrypted') + }) + + // Ordering pin: this column is BOTH declared plaintext (0000) and made + // encrypted by a previous sweep (0001). `already-encrypted` is the more + // specific reason and must win over `source-unknown`. + it('reports already-encrypted even when the column was also declared plaintext', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + 'CREATE TABLE "users" ("id" integer PRIMARY KEY, "email" text);\n', + ) + fs.writeFileSync( + path.join(tmpDir, '0001_swept.sql'), + [ + 'ALTER TABLE "users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v3_text_eq";', + '--> statement-breakpoint', + 'ALTER TABLE "users" DROP COLUMN "email";', + '--> statement-breakpoint', + 'ALTER TABLE "users" RENAME COLUMN "email__cipherstash_tmp" TO "email";', + '', + ].join('\n'), + ) + const alter = path.join(tmpDir, '0002_domain-change.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toHaveLength(1) + expect(skipped[0].reason).toBe('already-encrypted') + }) + + // A commented-out encrypted column line inside an otherwise LIVE + // CREATE TABLE must still count as encrypted. The comment check applies + // only to the DECLARED scan (over-detecting plaintext costs data); the + // ENCRYPTED scan never re-checks comments inside a live CREATE TABLE, so + // this stays over-detecting — the safe direction — exactly as it was + // before the corpus learned to track declarations at all. + it('still counts a commented-out encrypted column inside a live CREATE TABLE as encrypted', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + [ + 'CREATE TABLE "users" (', + '\t"id" integer,', + '\t-- "email" "public"."eql_v3_text_eq",', + '\t"email" text', + ');', + '', + ].join('\n'), + ) + const alter = path.join(tmpDir, '0001_encrypt.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toHaveLength(1) + expect(skipped[0].reason).toBe('already-encrypted') + }) + + // #772 review, finding 3. The index reads CREATE TABLE, ADD COLUMN and + // RENAME, but never the strict matcher's OWN target. So a corpus where an + // earlier migration already converted the column — realistic wherever a + // previous stack version's ALTER ran before this sweep existed — leaves + // that column looking plaintext, and the SECOND conversion rewrites a + // column that now holds ciphertext. `declared` cannot catch it: the column + // IS declared, as plaintext, by the original CREATE TABLE. + it('rewrites only the first conversion when two ALTERs chain on one column', async () => { + declarePlaintext('"users"', 'email') + const first = path.join(tmpDir, '0002_v2.sql') + fs.writeFileSync( + first, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v2_encrypted;\n', + ) + const secondSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const second = path.join(tmpDir, '0003_v3.sql') + fs.writeFileSync(second, `${secondSql}\n`) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + // The first conversion is the legitimate plaintext -> encrypted one and + // must still happen — flagging it too would be the naive fix. + expect(rewritten).toEqual([first]) + expect(fs.readFileSync(first, 'utf-8')).toContain('DROP COLUMN') + // The second targets ciphertext. Left byte-identical on disk. + expect(fs.readFileSync(second, 'utf-8')).toBe(`${secondSql}\n`) + expect(skipped).toEqual([ + { file: second, statement: secondSql, reason: 'already-encrypted' }, + ]) + }) + + it('flags the second of two chained ALTERs inside a single file', async () => { + declarePlaintext('"users"', 'email') + const secondSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const filePath = path.join(tmpDir, '0002_chain.sql') + fs.writeFileSync( + filePath, + [ + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v2_encrypted;', + secondSql, + '', + ].join('\n'), + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([filePath]) + const updated = fs.readFileSync(filePath, 'utf-8') + // Exactly one conversion was applied, and the v3 statement survives verbatim. + expect(updated.match(/DROP COLUMN/g)?.length).toBe(1) + expect(updated).toContain(secondSql) + expect(skipped).toEqual([ + { file: filePath, statement: secondSql, reason: 'already-encrypted' }, + ]) + }) + + // A chain on DIFFERENT columns is not a chain — each is its own first + // conversion and both must be rewritten. + it('rewrites both when chained ALTERs target different columns', async () => { + declarePlaintext('"users"', 'email', 'name') + const first = path.join(tmpDir, '0002_email.sql') + fs.writeFileSync( + first, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + const second = path.join(tmpDir, '0003_name.sql') + fs.writeFileSync( + second, + 'ALTER TABLE "users" ALTER COLUMN "name" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([first, second]) + expect(skipped).toEqual([]) + }) + + // A commented-out first conversion never ran, so the column is still + // plaintext and the live second conversion is the legitimate one. + it('does not treat a commented-out earlier ALTER as having encrypted the column', async () => { + declarePlaintext('"users"', 'email') + fs.writeFileSync( + path.join(tmpDir, '0002_v2.sql'), + '-- ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v2_encrypted;\n', + ) + const live = path.join(tmpDir, '0003_v3.sql') + fs.writeFileSync( + live, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([live]) + expect(fs.readFileSync(live, 'utf-8')).toContain('DROP COLUMN') + }) + + // #772 review, finding 4. `columnKey` keys on the schema exactly as written, + // but Postgres resolves an unqualified name through `search_path` — so + // `"users"` and `"public"."users"` are the SAME table. drizzle-kit emits + // unqualified; hand-written SQL and this sweep's own output are qualified. + // A corpus mixing the two split the index across two keys. + it('treats "public"."users" and "users" as the same table when checking encryption', async () => { + // 0000 declares the column unqualified — drizzle-kit's default output. + fs.writeFileSync( + path.join(tmpDir, '0000_declare.sql'), + 'CREATE TABLE "users" ("email" text);\n', + ) + // 0001 is a previous sweep's output, schema-qualified. The RENAME leaves + // `email` holding ciphertext, recorded under the QUALIFIED key. + fs.writeFileSync( + path.join(tmpDir, '0001_encrypt.sql'), + [ + 'ALTER TABLE "public"."users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v3_text_eq";', + 'ALTER TABLE "public"."users" DROP COLUMN "email";', + 'ALTER TABLE "public"."users" RENAME COLUMN "email__cipherstash_tmp" TO "email";', + '', + ].join('\n'), + ) + // 0002 changes the domain, written unqualified. + const alterSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const alter = path.join(tmpDir, '0002_domain-change.sql') + fs.writeFileSync(alter, `${alterSql}\n`) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(fs.readFileSync(alter, 'utf-8')).toBe(`${alterSql}\n`) + expect(skipped).toEqual([ + { file: alter, statement: alterSql, reason: 'already-encrypted' }, + ]) + }) + + // The mirror direction: declared/encrypted unqualified, altered qualified. + // Before the fix this reported `source-unknown` — a WRONG reason, telling + // the user the corpus never declares a column it declares two files up. + it('resolves an unqualified declaration against a schema-qualified ALTER', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + 'CREATE TABLE "users" ("email" "public"."eql_v3_text_eq");\n', + ) + const alterSql = + 'ALTER TABLE "public"."users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const alter = path.join(tmpDir, '0001_domain-change.sql') + fs.writeFileSync(alter, `${alterSql}\n`) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([ + { file: alter, statement: alterSql, reason: 'already-encrypted' }, + ]) + }) + + // #772 review, finding 2. CREATE_TABLE_RE's body is lazy up to the first + // `)\s*;`, which can sit inside a `--` comment or a string DEFAULT. The + // body is then truncated and every column declared after that point is + // lost from BOTH indexes — so an encrypted column reads as undeclared. + it('indexes columns declared after a ");" inside a comment in the CREATE TABLE body', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + [ + 'CREATE TABLE "users" (', + '\t"id" text, -- pk (uuid);', + '\t"email" "public"."eql_v3_text_eq"', + ');', + '', + ].join('\n'), + ) + const alterSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const alter = path.join(tmpDir, '0001_domain-change.sql') + fs.writeFileSync(alter, `${alterSql}\n`) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([ + { file: alter, statement: alterSql, reason: 'already-encrypted' }, + ]) + }) + + it('indexes columns declared after a ");" inside a string DEFAULT', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + [ + 'CREATE TABLE "users" (', + '\t"note" text DEFAULT \'see (ticket);\',', + '\t"email" "public"."eql_v3_text_eq"', + ');', + '', + ].join('\n'), + ) + const alterSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const alter = path.join(tmpDir, '0001_domain-change.sql') + fs.writeFileSync(alter, `${alterSql}\n`) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([ + { file: alter, statement: alterSql, reason: 'already-encrypted' }, + ]) + }) + + // The destructive composition: the truncated body loses the encrypted + // column, but a LATER migration re-declares the table so `declared` is + // satisfied from elsewhere — the fail-closed rule passes and the ALTER + // drops a column full of ciphertext. + it('does not drop ciphertext when a truncated CREATE TABLE body hides the encrypted column', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_declare.sql'), + 'CREATE TABLE "users" ("email" text);\n', + ) + fs.writeFileSync( + path.join(tmpDir, '0001_recreate.sql'), + [ + 'DROP TABLE "users";', + '--> statement-breakpoint', + 'CREATE TABLE "users" (', + '\t"id" text, -- pk (uuid);', + '\t"email" "public"."eql_v3_text_eq"', + ');', + '', + ].join('\n'), + ) + const alterSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const alter = path.join(tmpDir, '0002_domain-change.sql') + fs.writeFileSync(alter, `${alterSql}\n`) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(fs.readFileSync(alter, 'utf-8')).toBe(`${alterSql}\n`) + expect(skipped).toEqual([ + { file: alter, statement: alterSql, reason: 'already-encrypted' }, + ]) + }) + + // Only the IMPLICIT schema collapses. A real non-public schema is a + // genuinely different table and must not be conflated with the bare name. + it('does not conflate a non-public schema with the unqualified table', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_app.sql'), + 'CREATE TABLE "app"."users" ("email" "public"."eql_v3_text_eq");\n', + ) + fs.writeFileSync( + path.join(tmpDir, '0001_public.sql'), + 'CREATE TABLE "users" ("email" text);\n', + ) + const alter = path.join(tmpDir, '0002_encrypt.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + // public.users.email is plaintext — app.users.email being encrypted is + // about a different table entirely. + expect(rewritten).toEqual([alter]) + expect(skipped).toEqual([]) + }) + }) + + it('handles multiple ALTER statements in one file', async () => { + declarePlaintext('"a"', 'x', 'y') + const original = [ + 'ALTER TABLE "a" ALTER COLUMN "x" SET DATA TYPE eql_v3_text_search;', + 'ALTER TABLE "a" ALTER COLUMN "y" SET DATA TYPE eql_v3_text_search;', + 'CREATE INDEX "a_z" ON "a" ("z");', + ].join('\n') + const filePath = path.join(tmpDir, '0004_multi.sql') + fs.writeFileSync(filePath, original) + + await rewriteEncryptedAlterColumns(tmpDir) + + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated.match(/ADD COLUMN/g)?.length).toBe(2) + expect(updated.match(/DROP COLUMN/g)?.length).toBe(2) + // Non-matching statement preserved + expect(updated).toContain('CREATE INDEX "a_z" ON "a" ("z");') + }) + + // Regression pin, not a bug fix — the matchers carry `/gi`, so a + // hand-lowercased migration is rewritten just like drizzle-kit's output. + it('rewrites a lowercase alter table ... set data type', async () => { + declarePlaintext('"users"', 'email') + const filePath = path.join(tmpDir, '0034_lowercase.sql') + fs.writeFileSync( + filePath, + 'alter table "users" alter column "email" set data type eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([filePath]) + expect(skipped).toEqual([]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + 'ALTER TABLE "users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v3_text_search";', + ) + expect(updated).toContain('ALTER TABLE "users" DROP COLUMN "email";') + expect(updated).not.toMatch(/set data type/i) + }) + + // A-2: the sweep is FAIL-CLOSED. A column the corpus never declares is + // UNKNOWN, not plaintext — it may already hold ciphertext, with its + // declaration sitting in a migration directory this sweep never sees. + describe('columns the corpus does not declare', () => { + it('refuses to rewrite a column the corpus never declares', async () => { + const alterSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const alter = path.join(tmpDir, '0001_encrypt.sql') + fs.writeFileSync(alter, `${alterSql}\n`) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + const updated = fs.readFileSync(alter, 'utf-8') + expect(updated).toBe(`${alterSql}\n`) + expect(updated).not.toContain('DROP COLUMN') + expect(skipped).toEqual([ + { file: alter, statement: alterSql, reason: 'source-unknown' }, + ]) + }) + + it('rewrites a column declared plaintext by an ADD COLUMN', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_add.sql'), + 'ALTER TABLE "users" ADD COLUMN "email" text;\n', + ) + const alter = path.join(tmpDir, '0001_encrypt.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([alter]) + expect(skipped).toEqual([]) + }) + + // The skipped file is still part of the corpus: a column's current type + // comes from the migrations that ran before this one, edit-eligible or not. + it('counts a declaration living in the file passed to options.skip', async () => { + const install = path.join(tmpDir, '0000_install-eql.sql') + fs.writeFileSync( + install, + 'CREATE TABLE "users" ("id" integer PRIMARY KEY, "email" text);\n', + ) + const alter = path.join(tmpDir, '0002_encrypt.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns( + tmpDir, + { + skip: install, + }, + ) + + expect(rewritten).toEqual([alter]) + expect(skipped).toEqual([]) + }) + + it('follows a RENAME when deciding a column is declared', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + [ + 'CREATE TABLE "users" ("id" integer PRIMARY KEY, "email_address" text);', + '--> statement-breakpoint', + 'ALTER TABLE "users" RENAME COLUMN "email_address" TO "email";', + '', + ].join('\n'), + ) + const alter = path.join(tmpDir, '0001_encrypt.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([alter]) + expect(skipped).toEqual([]) + }) + + // A name inside a table/key constraint is a MENTION, not a declaration. + // Here every mention is followed by `)` or `,`, which the declaration + // regex's tail alone already rejects. A mention followed by a SQL keyword + // instead (e.g. `CHECK ("email" IS NOT NULL)`) is a separate case, closed + // by the regex's keyword lookahead and pinned by its own tests below. + // Counting either would put the rewrite back on the fail-open path. + it('does not treat a name inside PRIMARY KEY / REFERENCES as a declaration', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + [ + 'CREATE TABLE "sessions" (', + '\t"id" integer,', + '\t"user_id" integer,', + '\tPRIMARY KEY ("id", "email"),', + '\tFOREIGN KEY ("user_id") REFERENCES "users"("email")', + ');', + '', + ].join('\n'), + ) + const alterSql = + 'ALTER TABLE "sessions" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const alter = path.join(tmpDir, '0001_encrypt.sql') + fs.writeFileSync(alter, `${alterSql}\n`) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([ + { file: alter, statement: alterSql, reason: 'source-unknown' }, + ]) + }) + + // A column line commented out INSIDE a live CREATE TABLE never ran, so it + // declares nothing. + it('does not count a column commented out inside a live CREATE TABLE', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + [ + 'CREATE TABLE "users" (', + '\t"id" integer PRIMARY KEY,', + '\t-- "email" text,', + '\t"name" text', + ');', + '', + ].join('\n'), + ) + const alter = path.join(tmpDir, '0001_encrypt.sql') + fs.writeFileSync( + alter, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toHaveLength(1) + expect(skipped[0].reason).toBe('source-unknown') + }) + + // A CHECK predicate mentioning a column has the same `"name" <letter>` + // shape as a declaration — `"email" IS` — but IS is a predicate keyword, + // not a type token. Without the keyword lookahead this would read as a + // declaration and rewrite the column, dropping any ciphertext it already + // holds via a declaration this sweep never sees. + it('does not treat a CHECK predicate mention as a declaration', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + [ + 'CREATE TABLE "users" (', + '\t"id" integer PRIMARY KEY,', + '\tCONSTRAINT "c1" CHECK ("email" IS NOT NULL)', + ');', + '', + ].join('\n'), + ) + const alterSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const alter = path.join(tmpDir, '0001_encrypt.sql') + fs.writeFileSync(alter, `${alterSql}\n`) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + const updated = fs.readFileSync(alter, 'utf-8') + expect(updated).toBe(`${alterSql}\n`) + expect(updated).not.toContain('DROP COLUMN') + expect(skipped).toEqual([ + { file: alter, statement: alterSql, reason: 'source-unknown' }, + ]) + }) + + // A constraint's NAME can coincide with a column's name — drizzle's + // `<table>_<col>_unique` convention makes this contrived, but the keyword + // lookahead closes it regardless: a constraint name is always followed + // immediately by its constraint-type keyword (UNIQUE here), which the + // lookahead excludes. + it('does not let a same-named constraint declare the column', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + [ + 'CREATE TABLE "users" (', + '\t"id" integer PRIMARY KEY,', + '\tCONSTRAINT "email" UNIQUE("id")', + ');', + '', + ].join('\n'), + ) + const alterSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const alter = path.join(tmpDir, '0001_encrypt.sql') + fs.writeFileSync(alter, `${alterSql}\n`) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toEqual([ + { file: alter, statement: alterSql, reason: 'source-unknown' }, + ]) + }) + + // The keyword lookahead is pinned to a word boundary specifically so it + // does not eat real type names that merely START WITH a blocked + // keyword's letters: `interval`/`inet` both start "in" (colliding with + // IN) but must still count as genuine declarations. + it('still declares a column whose type name starts with a blocked keyword', async () => { + fs.writeFileSync( + path.join(tmpDir, '0000_create.sql'), + 'CREATE TABLE "events" ("email" interval, "note" inet);\n', + ) + const alter = path.join(tmpDir, '0001_encrypt.sql') + fs.writeFileSync( + alter, + [ + 'ALTER TABLE "events" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + 'ALTER TABLE "events" ALTER COLUMN "note" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\n'), + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([alter]) + expect(skipped).toEqual([]) + }) + }) +}) + +describe('sweepMigrationDirs', () => { + let tmpDir: string + + const ALTER = [ + // Fail-closed: the sweep rewrites only a column the corpus DECLARES, and a + // real drizzle corpus carries the CREATE that declared it. + 'CREATE TABLE "users" ("id" integer PRIMARY KEY, "email" text);', + '--> statement-breakpoint', + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\n') + + /** + * Create a drizzle-kit OUTPUT directory under the sandbox, optionally seeding + * `name` with `sql`. + * + * The `meta/_journal.json` is what makes it drizzle-kit's: the sweep now + * requires it, because `migrations/` and `src/db/migrations/` are generic + * names that Knex, Flyway, node-pg-migrate and raw psql also use, and this + * sweep emits `DROP COLUMN`. Use {@link seedForeignDir} for the other case. + */ + const seedDir = (dir: string, name?: string, sql?: string): string => { + const abs = seedForeignDir(dir, name, sql) + fs.mkdirSync(path.join(abs, 'meta'), { recursive: true }) + fs.writeFileSync( + path.join(abs, 'meta', '_journal.json'), + JSON.stringify({ version: '7', dialect: 'postgresql', entries: [] }), + ) + return abs + } + + /** A migration directory belonging to some OTHER tool — no drizzle journal. */ + const seedForeignDir = (dir: string, name?: string, sql?: string): string => { + const abs = path.join(tmpDir, dir) + fs.mkdirSync(abs, { recursive: true }) + if (name) fs.writeFileSync(path.join(abs, name), sql ?? ALTER) + return abs + } + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wizard-sweep-')) + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('skips candidate directories that do not exist', async () => { + const abs = seedDir('migrations', '0001_alter.sql') + + const results = await sweepMigrationDirs(tmpDir, ['drizzle', 'migrations']) + + expect(results.map((r) => r.dir)).toEqual(['migrations']) + expect(results[0].rewritten).toEqual([path.join(abs, '0001_alter.sql')]) + }) + + // The regression this test locks in: the old loop `return`ed after the FIRST + // existing candidate, so a project with an empty `drizzle/` alongside a real + // `migrations/` had its actual migrations silently left unrepaired. + it('keeps sweeping when an earlier candidate directory yields no matches', async () => { + seedDir('drizzle') + const abs = seedDir('migrations', '0002_alter.sql') + + const results = await sweepMigrationDirs(tmpDir, ['drizzle', 'migrations']) + + expect(results.map((r) => r.dir)).toEqual(['drizzle', 'migrations']) + expect(results[0].rewritten).toEqual([]) + expect(results[1].rewritten).toEqual([path.join(abs, '0002_alter.sql')]) + expect( + fs.readFileSync(path.join(abs, '0002_alter.sql'), 'utf-8'), + ).not.toContain('SET DATA TYPE') + }) + + it('rewrites every candidate directory that holds encrypted alters', async () => { + const drizzle = seedDir('drizzle', '0001_alter.sql') + const nested = seedDir('src/db/migrations', '0002_alter.sql') + + const results = await sweepMigrationDirs(tmpDir, [ + 'drizzle', + 'src/db/migrations', + ]) + + expect(results.flatMap((r) => r.rewritten)).toEqual([ + path.join(drizzle, '0001_alter.sql'), + path.join(nested, '0002_alter.sql'), + ]) + }) + + it('is idempotent — a second sweep rewrites nothing', async () => { + const abs = seedDir('drizzle', '0001_alter.sql') + + await sweepMigrationDirs(tmpDir, ['drizzle']) + const afterFirst = fs.readFileSync( + path.join(abs, '0001_alter.sql'), + 'utf-8', + ) + const results = await sweepMigrationDirs(tmpDir, ['drizzle']) + + expect(results[0].rewritten).toEqual([]) + expect(fs.readFileSync(path.join(abs, '0001_alter.sql'), 'utf-8')).toBe( + afterFirst, + ) + }) + + it('surfaces a failing directory as an error and still sweeps the rest', async () => { + // A directory named `*.sql` makes readFile throw EISDIR mid-sweep. + const broken = seedDir('drizzle') + fs.mkdirSync(path.join(broken, '0001_alter.sql')) + const abs = seedDir('migrations', '0002_alter.sql') + + const results = await sweepMigrationDirs(tmpDir, ['drizzle', 'migrations']) + + expect(results[0].dir).toBe('drizzle') + expect(results[0].error).toBeDefined() + expect(results[1].rewritten).toEqual([path.join(abs, '0002_alter.sql')]) + }) + + it('reports near-misses per directory', async () => { + const abs = seedDir( + 'drizzle', + '0001_using.sql', + 'ALTER TABLE "t" ALTER COLUMN "c" SET DATA TYPE eql_v3_json USING (c)::jsonb;\n', + ) + + const results = await sweepMigrationDirs(tmpDir, ['drizzle']) + + expect(results[0].skipped).toHaveLength(1) + expect(results[0].skipped[0].file).toBe(path.join(abs, '0001_using.sql')) + }) + + // A-2 trigger (b). The wizard ships scanning three candidate directories and + // indexes each separately, so a declaration in `drizzle/` is invisible to the + // sweep of `migrations/`. That column is already ciphertext; rewriting it + // emits a live DROP COLUMN with nothing anywhere to backfill from. + it('does not rewrite an ALTER whose column is declared in a sibling directory', async () => { + seedDir( + 'drizzle', + '0000_create.sql', + 'CREATE TABLE "users" ("id" integer PRIMARY KEY, "email" "public"."eql_v3_text_eq");\n', + ) + const alterSql = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;' + const migrations = seedDir( + 'migrations', + '0001_domain-change.sql', + `${alterSql}\n`, + ) + const alter = path.join(migrations, '0001_domain-change.sql') + + const results = await sweepMigrationDirs(tmpDir, ['drizzle', 'migrations']) + + const swept = results.find((result) => result.dir === 'migrations') + expect(swept?.rewritten).toEqual([]) + expect(swept?.skipped).toEqual([ + { file: alter, statement: alterSql, reason: 'source-unknown' }, + ]) + const updated = fs.readFileSync(alter, 'utf-8') + expect(updated).toBe(`${alterSql}\n`) + expect(updated).not.toContain('DROP COLUMN') + }) + + // #772 review, finding 5. `migrations/` and `src/db/migrations/` are generic + // names — Knex, node-pg-migrate, Flyway and hand-rolled psql all use them. + // Sweeping every candidate that merely EXISTS meant a project whose drizzle + // `out` is `drizzle/` but which also keeps a hand-maintained `migrations/` + // had that second directory rewritten into ADD+DROP+RENAME, in a directory + // this tool was never pointed at. The fail-closed `declared` rule does not + // help: a real migration history declares its own columns. + // + // drizzle-kit always writes `meta/_journal.json` into its output directory; + // none of the other tools do. That is the discriminator. + it('does not sweep a directory that is not a drizzle-kit output', async () => { + const foreign = seedForeignDir('migrations', '0001_alter.sql') + const alter = path.join(foreign, '0001_alter.sql') + + const results = await sweepMigrationDirs(tmpDir, ['migrations']) + + expect(results.find((r) => r.dir === 'migrations')?.rewritten).toEqual([]) + const updated = fs.readFileSync(alter, 'utf-8') + expect(updated).toBe(ALTER) + expect(updated).not.toContain('DROP COLUMN') + }) + + // Silence would be its own failure: a genuine drizzle directory whose meta/ + // was deleted must not simply do nothing without saying so. + it('reports a skipped directory that holds SQL but no drizzle journal', async () => { + seedForeignDir('migrations', '0001_alter.sql') + + const results = await sweepMigrationDirs(tmpDir, ['migrations']) + + expect(results.find((r) => r.dir === 'migrations')?.notDrizzleOutput).toBe( + true, + ) + }) + + // An empty foreign directory is not worth mentioning — there is nothing in it + // the sweep could have repaired. + it('does not report a journal-less directory that holds no SQL', async () => { + seedForeignDir('migrations') + + const results = await sweepMigrationDirs(tmpDir, ['migrations']) + + expect(results.find((r) => r.dir === 'migrations')?.notDrizzleOutput).toBe( + undefined, + ) + }) + + // The blast-radius fix must not shrink the legitimate reach: a real drizzle + // output directory that is NOT the first candidate is still swept. + it('still sweeps a drizzle output directory that is not the first candidate', async () => { + seedDir( + 'drizzle', + '0000_noop.sql', + 'CREATE TABLE "widgets" ("id" integer);\n', + ) + const migrations = seedDir('migrations', '0001_alter.sql') + + const results = await sweepMigrationDirs(tmpDir, ['drizzle', 'migrations']) + + expect(results.find((r) => r.dir === 'migrations')?.rewritten).toEqual([ + path.join(migrations, '0001_alter.sql'), + ]) + }) +}) + +// The three reasons drive very different user action — re-encrypt through the +// staged lifecycle, fix a hand-authored cast, or go check the database. A +// switch with no `default` arm means a missing case fails the build, but a +// mis-MAPPED case (wiring `source-unknown` to the `already-encrypted` string) +// compiles fine and ships wrong remediation into a data-loss decision. Pin the +// mapping so that swap is caught. +describe('describeSkipReason', () => { + it('describes already-encrypted as a re-encrypt-through-the-lifecycle action', () => { + const text = describeSkipReason('already-encrypted') + expect(text).toContain('ALREADY encrypted') + expect(text).toContain('DROP the ciphertext') + expect(text).toContain('`stash encrypt` lifecycle') + }) + + it('describes unrecognised-form as a hand-authored / unknown cast', () => { + const text = describeSkipReason('unrecognised-form') + expect(text).toContain('SET DATA TYPE ... USING') + expect(text).toContain('fails at migrate time') + }) + + it('describes source-unknown as a go-check-the-database action', () => { + const text = describeSkipReason('source-unknown') + expect(text).toContain('could not find where this column was declared') + expect(text).toContain("Check the column's current type in the database") + }) + + it('gives each reason a distinct description', () => { + const reasons = [ + 'already-encrypted', + 'unrecognised-form', + 'source-unknown', + ] as const + const described = reasons.map(describeSkipReason) + expect(new Set(described).size).toBe(reasons.length) + }) +}) diff --git a/packages/wizard/src/lib/install-skills.ts b/packages/wizard/src/lib/install-skills.ts index 474bc2bdc..b3f5d2f96 100644 --- a/packages/wizard/src/lib/install-skills.ts +++ b/packages/wizard/src/lib/install-skills.ts @@ -13,14 +13,24 @@ import type { Integration } from './types.js' */ const SKILL_MAP: Record<Integration, readonly string[]> = { drizzle: ['stash-encryption', 'stash-drizzle', 'stash-indexing', 'stash-cli'], + // `stash-postgres` / `stash-edge` mirror the CLI's SKILL_MAP — see the comments + // there for why Supabase and the generic (no-ORM) path get them (#754). supabase: [ 'stash-encryption', 'stash-supabase', 'stash-indexing', + 'stash-postgres', + 'stash-edge', 'stash-cli', ], prisma: ['stash-encryption', 'stash-indexing', 'stash-cli'], - generic: ['stash-encryption', 'stash-indexing', 'stash-cli'], + generic: [ + 'stash-encryption', + 'stash-indexing', + 'stash-postgres', + 'stash-edge', + 'stash-cli', + ], } /** diff --git a/packages/wizard/src/lib/post-agent.ts b/packages/wizard/src/lib/post-agent.ts index 34c18f2a7..df8f7ec0c 100644 --- a/packages/wizard/src/lib/post-agent.ts +++ b/packages/wizard/src/lib/post-agent.ts @@ -6,11 +6,9 @@ */ import { execSync } from 'node:child_process' -import { existsSync } from 'node:fs' -import { resolve } from 'node:path' import * as p from '@clack/prompts' import type { GatheredContext } from './gather.js' -import { rewriteEncryptedAlterColumns } from './rewrite-migrations.js' +import { describeSkipReason, sweepMigrationDirs } from './rewrite-migrations.js' import type { DetectedPackageManager, Integration } from './types.js' interface PostAgentOptions { @@ -21,8 +19,10 @@ interface PostAgentOptions { } /** - * Candidate directories drizzle-kit may write migrations to. We check in - * order and rewrite the first one that exists; `drizzle` is the default. + * Candidate directories drizzle-kit may write migrations to. `drizzle` is the + * default, but a project's configured `out` is not discoverable from here, so + * every candidate that exists is swept — see {@link sweepMigrationDirs} for why + * stopping at the first one loses migrations. */ const DRIZZLE_OUT_DIRS = ['drizzle', 'migrations', 'src/db/migrations'] @@ -76,13 +76,53 @@ export async function runPostAgentSteps(opts: PostAgentOptions): Promise<void> { cwd, ) - // Rewrite any `ALTER COLUMN ... SET DATA TYPE eql_v2_encrypted` that - // drizzle-kit just produced — those fail in Postgres. CIP-2991 + CIP-2994. - await rewriteEncryptedMigrations(cwd) + // Rewrite any `ALTER COLUMN ... SET DATA TYPE <eql domain>` that + // drizzle-kit just produced — those fail in Postgres (no cast from + // text/numeric to an EQL domain). Covers the EQL v3 family the wizard now + // scaffolds, and legacy eql_v2_encrypted. CIP-2991 + CIP-2994 + #693. + const sweep = await rewriteEncryptedMigrations(cwd) + + // A rewritten file is a DROP+ADD in disguise — the next migrate destroys + // data on any table that already holds rows. A flagged statement never got + // that treatment: it is left on disk untouched, so nothing is destroyed by + // migrating, but a raw ALTER to an encrypted domain has no cast in + // Postgres and fails at migrate time until a human resolves it. Both + // default the prompt to NO — an `initialValue: true` immediately under + // either warning invites exactly the mistake the warning is about — but + // they need different words: claiming "DESTROYS data" for a migration + // that destroyed nothing is its own kind of wrong guidance. + const destructive = sweep.rewritten > 0 + const flaggedOnly = !destructive && sweep.skipped > 0 + + // A directory whose sweep threw contributes 0 to both totals, so on its own + // it is indistinguishable from a clean sweep — except that it means the + // opposite: those migrations may still hold unrepaired `SET DATA TYPE` + // statements and nobody has looked. `stash eql migration` / `db install` + // treat "sweep failed outright" and "sweep left near-misses" as the same + // state for the same reason; unknown is not safe, so the default is NO here + // too. The wording differs from the destructive case on purpose: nothing is + // known about that directory, so claiming it destroys data would be a guess. + const unverifiedDirs = sweep.failedDirs + const unverified = unverifiedDirs.length > 0 + const unverifiedList = unverifiedDirs.map((dir) => `${dir}/`).join(', ') + const unverifiedCount = `${unverifiedDirs.length} director${ + unverifiedDirs.length === 1 ? 'y' : 'ies' + }` + if (unverified) { + p.log.warn( + `The ALTER COLUMN sweep did not fully complete — review the sibling migrations in ${unverifiedList} before running drizzle-kit migrate, or you may apply broken/unsafe SQL.`, + ) + } const shouldMigrate = await p.confirm({ - message: `Run the migration now? (${runner} drizzle-kit migrate)`, - initialValue: true, + message: destructive + ? `Run the migration now? (${runner} drizzle-kit migrate) — see the warnings above: this migration DESTROYS data on any table that already holds rows` + : flaggedOnly + ? `Run the migration now? (${runner} drizzle-kit migrate) — statement(s) were flagged for review above rather than rewritten; nothing was destroyed, but the raw ALTER will fail at migrate time until they're resolved` + : unverified + ? `Run the migration now? (${runner} drizzle-kit migrate) — the sweep could not check ${unverifiedCount} (${unverifiedList}); review those migrations before migrating, or you may apply broken/unsafe SQL` + : `Run the migration now? (${runner} drizzle-kit migrate)`, + initialValue: !destructive && !flaggedOnly && !unverified, }) if (!p.isCancel(shouldMigrate) && shouldMigrate) { @@ -112,31 +152,74 @@ export async function runPostAgentSteps(opts: PostAgentOptions): Promise<void> { } } -async function rewriteEncryptedMigrations(cwd: string): Promise<void> { - for (const dir of DRIZZLE_OUT_DIRS) { - const abs = resolve(cwd, dir) - if (!existsSync(abs)) continue - - try { - const rewritten = await rewriteEncryptedAlterColumns(abs) - if (rewritten.length > 0) { - p.log.info( - `Rewrote ${rewritten.length} migration file(s) in ${dir}/ to use ADD+DROP+RENAME for encrypted columns.`, - ) - for (const file of rewritten) p.log.step(` - ${file}`) - p.log.warn( - 'If any of these tables already have rows, backfill the new column via @cipherstash/stack before running the migration in production. See the comments in the rewritten SQL.', - ) +/** + * Sweep the candidate migration directories, reporting what happened, and + * return the totals so the caller can decide how dangerous "run it now" is. + * + * `failedDirs` names the directories that exist but whose sweep threw. It is a + * third state, not a variant of "nothing to do": those migrations may still + * contain unrepaired `SET DATA TYPE` statements and went unchecked, which the + * `rewritten`/`skipped` counts cannot express — both stay 0 for such a + * directory, exactly as they do for a clean one. + */ +async function rewriteEncryptedMigrations(cwd: string): Promise<{ + rewritten: number + skipped: number + failedDirs: string[] +}> { + const results = await sweepMigrationDirs(cwd, DRIZZLE_OUT_DIRS) + const totals = { rewritten: 0, skipped: 0, failedDirs: [] as string[] } + + for (const { dir, rewritten, skipped, error, notDrizzleOutput } of results) { + totals.rewritten += rewritten.length + totals.skipped += skipped.length + + // Not a failure and not a risk — the directory belongs to some other tool, + // so `drizzle-kit migrate` will not run it and the prompt below is + // unaffected. Said out loud anyway, so a user whose drizzle output really + // does live here (meta/ deleted, or a hand-assembled directory) can see why + // nothing was repaired instead of assuming it was clean. + if (notDrizzleOutput) { + p.log.info( + `Left ${dir}/ alone — it holds .sql files but no drizzle-kit journal (meta/_journal.json), so it is not a drizzle output directory. If it IS your drizzle \`out\`, run \`drizzle-kit generate\` once to create the journal, then re-run the wizard.`, + ) + continue + } + + // Presence, not truthiness: `error` is `err.message` for a thrown `Error`, + // and `new Error()` has an empty message. Testing `if (error)` would put a + // blank-message failure back on the fail-open path this whole branch exists + // to close. + if (error !== undefined) { + totals.failedDirs.push(dir) + p.log.warn( + `Could not rewrite migrations in ${dir}: ${error || 'unknown error'}`, + ) + continue + } + + if (rewritten.length > 0) { + p.log.info( + `Rewrote ${rewritten.length} migration file(s) in ${dir}/ to use ADD+DROP+RENAME for encrypted columns.`, + ) + for (const file of rewritten) p.log.step(` - ${file}`) + p.log.warn( + 'This rewrite is data-destroying — safe only on an EMPTY table. If any of these tables already have rows, do NOT run the migration; use the staged `stash encrypt` flow (add -> backfill via @cipherstash/stack -> cutover -> drop) instead. See the comments in the rewritten SQL.', + ) + } + + if (skipped.length > 0) { + p.log.warn( + `${skipped.length} statement(s) look like an ALTER-to-encrypted that the rewrite left alone. Review them before migrating:`, + ) + for (const s of skipped) { + p.log.step(` - ${s.file}: ${s.statement}`) + p.log.step(` ${describeSkipReason(s.reason)}`) } - // Only rewrite the first dir that matches — running again on a - // different candidate would double-transform already-rewritten SQL. - return - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - p.log.warn(`Could not rewrite migrations in ${dir}: ${message}`) - return } } + + return totals } async function runStep( diff --git a/packages/wizard/src/lib/rewrite-migrations.ts b/packages/wizard/src/lib/rewrite-migrations.ts index 24635f10e..74393eac6 100644 --- a/packages/wizard/src/lib/rewrite-migrations.ts +++ b/packages/wizard/src/lib/rewrite-migrations.ts @@ -1,96 +1,999 @@ +import { existsSync } from 'node:fs' import { readdir, readFile, writeFile } from 'node:fs/promises' -import { join } from 'node:path' +import { join, resolve } from 'node:path' /** - * Matches drizzle-kit's generated in-place type change to the encrypted - * column type. drizzle-kit's ALTER COLUMN path wraps the customType - * `dataType()` return value in double-quotes and prepends `"{typeSchema}".`. - * Custom types have no `typeSchema`, so we see several mangled forms - * depending on what `dataType()` returned. We match all of them: + * The encrypted column types this rewrite applies to: the single EQL v2 type, + * and the whole EQL v3 concrete-domain family (`eql_v3_text_search`, + * `eql_v3_integer_ord`, …). The v3 side is matched by shape rather than by an + * enumerated list so a newly added domain is covered without touching this file + * — the `eql_v3_` prefix is unambiguous in a `SET DATA TYPE` position. * - * - bare `eql_v2_encrypted` → `"undefined"."eql_v2_encrypted"` - * - pre-quoted `"public"."eql_v2_encrypted"` (stack 0.15.0 regression) → - * `"undefined".""public"."eql_v2_encrypted""` - * - the plain `eql_v2_encrypted` and `"public"."eql_v2_encrypted"` forms, - * in case a future drizzle-kit release stops prepending undefined. + * v2 stays matched even though the wizard now scaffolds v3 exclusively: a user's + * migration directory can hold files generated against an older stack version, + * and existing v2 ciphertext stays valid — we still want their ALTER-to-encrypted + * statements repaired. + */ +const ENCRYPTED_DOMAIN = String.raw`eql_v2_encrypted|eql_v3_[a-z0-9_]+` + +/** + * Extracts the bare domain name out of whichever mangled form matched. Derived + * from {@link ENCRYPTED_DOMAIN} so the two can never drift out of sync — a new + * domain added to the alternation is extracted here for free. + */ +const DOMAIN_RE = new RegExp(ENCRYPTED_DOMAIN, 'i') + +/** + * The mangled forms drizzle-kit emits for a customType in an ALTER COLUMN. + * + * drizzle-kit's ALTER COLUMN path wraps the customType `dataType()` return + * value in double-quotes and prepends `"{typeSchema}".`. Custom types have no + * `typeSchema`, so the emitted SQL depends on BOTH what `dataType()` returned + * and which drizzle-kit renders it. Verified against 0.24.2, 0.28.1, 0.30.6, + * 0.31.0, 0.31.1 and 0.31.10 through `drizzle-kit/api`'s `generateMigration`: + * + * | `dataType()` returns | <= 0.30.6 | >= 0.31.0 | + * | ------------------------------- | --------------------------- | ---------------------------------- | + * | `eql_v3_text_search` (current) | `eql_v3_text_search` | `"undefined"."eql_v3_text_search"` | + * | `public.eql_v3_text_search` | `public.eql_v3_text_search` | `"undefined"."public.eql_v3_…"` | + * | `"public"."eql_v2_encrypted"` | `"public"."eql_v2_…"` | `"undefined".""public"."eql_v2_…"` | + * + * The `"undefined".` prefix is a **0.31.0-and-later** behaviour — 0.30.6 and + * earlier emit the plain form. All six forms stay matched because a user's + * migration directory can hold files generated by any drizzle-kit version + * against any stack version — including the qualified `public.eql_v3_*` that + * stack emitted before the unqualified fix. + * + * Ordered longest-first so the alternation cannot match a prefix of a longer + * form and leave a trailing fragment behind. + * + * NOTE: this file is the sibling of `packages/cli/src/commands/db/rewrite-migrations.ts`, + * which cli's `stash eql migration --drizzle` uses. Both are tightly coupled to + * drizzle-kit's output format — if drizzle-kit changes, both need updating + * together. Keep them in sync. + */ +const MANGLED_TYPE_FORMS = [ + String.raw`"undefined"\.""public"\."(?:${ENCRYPTED_DOMAIN})""`, + String.raw`"undefined"\."public\.(?:${ENCRYPTED_DOMAIN})"`, + String.raw`"undefined"\."(?:${ENCRYPTED_DOMAIN})"`, + String.raw`"public"\."(?:${ENCRYPTED_DOMAIN})"`, + String.raw`public\.(?:${ENCRYPTED_DOMAIN})`, + // Not emitted by any released drizzle-kit ALTER path, but it is the shape the + // CREATE TABLE path renders — guard it in case ALTER converges on it. + String.raw`"(?:${ENCRYPTED_DOMAIN})"`, + String.raw`(?:${ENCRYPTED_DOMAIN})`, +].join('|') + +/** + * Matches drizzle-kit's generated in-place type change to an encrypted column + * type, in any of the forms above. * * Captures: - * - $1: table name (without quotes) - * - $2: column name (without quotes) + * - $1: table name, OR the schema when a qualifier follows (both without quotes) + * - $2: table name when schema-qualified (`"app"."users"`), else undefined + * - $3: column name (without quotes) + * - $4: the mangled type blob — feed it to {@link DOMAIN_RE} for the bare name + * + * The optional `(?:\."([^"]+)")?` after the first quoted name matches the + * schema qualifier drizzle-kit emits for a `pgSchema()` table (`"app"."users"`). + */ +const ALTER_COLUMN_TO_ENCRYPTED_RE = new RegExp( + // `\s*;` — not `[^;]*;` — so the type blob must run straight into the + // statement terminator. drizzle-kit emits exactly that (no trailing clause), + // and the tighter tail means a HAND-AUTHORED `SET DATA TYPE … USING <expr>;` + // is left untouched instead of being silently rewritten (and its USING + // discarded). + String.raw`ALTER TABLE "([^"]+)"(?:\."([^"]+)")?\s+ALTER COLUMN "([^"]+)"\s+SET DATA TYPE (${MANGLED_TYPE_FORMS})\s*;`, + 'gi', +) + +/** + * A deliberately BROAD scan for statements that look like an in-place change to + * an encrypted column but that the strict {@link ALTER_COLUMN_TO_ENCRYPTED_RE} + * did not rewrite — a hand-authored `SET DATA TYPE … USING …;`, or some future + * drizzle-kit form the strict matcher doesn't yet cover. + * + * It matches any single (`;`-terminated) statement that contains both + * `SET DATA TYPE` and an `eql_v2`/`eql_v3` domain token at a word boundary (so + * lookalikes like `eql_v4_…` or `not_eql_v3_…` don't trip it). Run AFTER the + * strict rewrite so genuinely-rewritten statements — which no longer contain + * `SET DATA TYPE` — are already gone; whatever this still finds is a near-miss + * we flag for human review rather than silently ship as broken SQL. + */ +const NEAR_MISS_RE = + /[^;]*?\bSET\s+DATA\s+TYPE\b[^;]*?\beql_v[23][a-z0-9_]*[^;]*?;/gi + +/** + * Strips any run of blank lines, `--` line comments (including drizzle-kit's + * `--> statement-breakpoint`), and `/* … *\/` block comments — in any order, + * repeated as many times as they occur — from the head of a + * {@link NEAR_MISS_RE} match. + * + * That regex opens with a lazy `[^;]*?`, whose only left boundary is the + * previous `;` — or the start of the file when there is no preceding statement. + * So the raw match drags in every comment and blank line since then, and a + * near-miss preceded by a comment block gets reported to the user with that + * whole block glued to its front. Strip the preamble so the statement we quote + * back reads as the offending statement alone. + * + * The block comment is matched as its OWN loop alternative rather than a + * single group anchored ahead of the line-comment loop, because the latter + * only works when the block comment sits at the very start of the file: in + * the far more common case — a preceding statement earlier in the same file — + * the match starts at THAT statement's `;`, so it opens with the newline + * after it, not with the comment. An anchored `^(?:/\*…\*\/\s*)?` can't match + * past that newline to reach the comment, so it silently matches nothing and + * leaves the comment attached to the reported statement. Folding the block + * comment into the repeating loop lets it match after any number of leading + * newlines/line-comments, in any interleaving. + * + * The block comment strip is NOT cosmetic: a statement the strict + * {@link ALTER_COLUMN_TO_ENCRYPTED_RE} matched but skipped (`already-encrypted` + * or `source-unknown`) is left on disk unchanged, so it still contains + * `SET DATA TYPE` and the broad scan below finds it again. Without stripping + * the block comment here, that second pass reports a DIFFERENT statement string + * (comment glued to the front) than the strict pass's `match.trim()`, so + * {@link rewriteEncryptedAlterColumns}'s dedup key never matches and the same + * statement is reported twice — once with the correct reason, once as + * `unrecognised-form`, whose guidance (look for a hand-authored `USING`) is + * wrong for a statement the strict matcher already matched. Stripping the + * comment here makes both passes agree on the statement text so the second + * report collapses into the first. + * + * Known residue, accepted rather than fixed: a NESTED closed block comment + * ahead of a live ALTER (`/* outer /* inner *\/ still *\/`) still + * double-reports. The block-comment alternative's `*?` is lazy, so it stops + * at the FIRST `*\/` — consuming only `/* outer /* inner *\/` — and leaves + * `` still *\/`` glued to the front of the next iteration, which the + * line-comment alternative doesn't recognise either. That residual text rides + * along into the `unrecognised-form` report. + */ +const STATEMENT_PREAMBLE_RE = + /^(?:\s*\/\*[\s\S]*?\*\/|[^\S\n]*(?:--[^\n]*)?\n)*/ + +/** Drop the leading blank/comment lines a `[^;]*?`-anchored match dragged in. */ +function trimStatementPreamble(statement: string): string { + return statement.replace(STATEMENT_PREAMBLE_RE, '').trim() +} + +/** + * True when the character at `index` is INERT — it sits inside a SQL comment (a + * `--` line comment, or a nestable block comment) or inside a single-quoted + * string literal. Either way it is not a statement the database will execute, + * so the rewrite must leave it exactly as written. + * + * **Why the rewrite needs this:** {@link ALTER_COLUMN_TO_ENCRYPTED_RE} is + * comment-blind and {@link renderSafeAlter} returns MULTIPLE lines. Rewriting a + * commented-out `-- ALTER TABLE … SET DATA TYPE …;` therefore leaves the + * author's `-- ` prefix on line 1 only — lines 2+, including `DROP COLUMN`, + * become live executable SQL and destroy the column. Commented SQL is inert by + * definition, so the only correct move is to leave it exactly as written. + * + * **Why string literals count as inert too:** an ALTER quoted inside an + * `INSERT … VALUES ('…')` is DATA. Rewriting it splices `--> statement-breakpoint` + * markers INSIDE the literal, so splitting the file the way drizzle's migrator + * does yields a bare, live `ALTER TABLE … DROP COLUMN …;` as a chunk of its own. + * Escaping the injected text's own apostrophe (`@cipherstash/stack's`) would fix + * only the syntax error, not that live DROP COLUMN — so the statement is left + * as written, exactly like a commented one. + * + * Double-quoted identifiers are tokenised BEFORE `'` is considered: an + * apostrophe inside `"o'brien_data"` must not open a phantom literal, or the + * scan runs to the NEXT apostrophe — typically the same identifier in a + * commented-out ALTER further down — decides that ALTER is live, and rewrites + * it into a real `DROP COLUMN`. A doubled delimiter (`''` in a literal, `""` in + * an identifier) is an escape and does not close the token. + * + * Dollar-quoted bodies (`$$ … $$`, `$fn$ … $fn$`) are skipped whole, and an + * `E''` literal gives backslash its escape meaning. Both are quote-PARITY + * concerns, not merely "we might skip a rewrite": an apostrophe inside an + * untracked `$$` body, or a `\'` read as a close, ends a literal EARLY, and + * every token after it is then misread — including a commented-out ALTER, which + * reads as live and gets rewritten into a live DROP COLUMN. That is the same + * failure mode as the apostrophe-in-identifier case below, and it is why the + * earlier reasoning here ("can only make us skip") was wrong: it holds for an + * UNDER-terminated literal, not an over-terminated one (#772 review, finding 1). + */ +function isInsideCommentOrString(sql: string, index: number): boolean { + let i = 0 + while (i < index) { + if (sql.startsWith('--', i)) { + const eol = sql.indexOf('\n', i) + if (eol === -1 || eol >= index) return true + i = eol + 1 + } else if (sql.startsWith('/*', i)) { + // Postgres block comments nest, so track depth rather than stopping at + // the first `*/` — a nested close would otherwise end the comment early + // and let the text after it read as live SQL. + let depth = 1 + let j = i + 2 + while (j < sql.length && depth > 0) { + if (sql.startsWith('/*', j)) { + depth += 1 + j += 2 + } else if (sql.startsWith('*/', j)) { + depth -= 1 + j += 2 + } else { + j += 1 + } + } + if (j > index) return true + i = j + } else if (sql[i] === '$') { + // A `$tag$ … $tag$` body is opaque: its content is not SQL, so a `'` or + // `--` inside it means nothing. Skipping the whole body is what keeps the + // rest of the file's quote parity aligned with Postgres. + const delimiter = dollarQuoteDelimiter(sql, i) + if (delimiter === undefined) { + i += 1 + } else { + const close = sql.indexOf(delimiter, i + delimiter.length) + // Unterminated: inert to the end of the file, like the cases below. + const end = close === -1 ? sql.length : close + delimiter.length + if (end > index) return true + i = end + } + } else if (sql[i] === '"') { + // A quoted identifier is live SQL, but its body is not: consuming it here + // — before the `'` branch below — is what stops an apostrophe inside one + // from opening a string literal that never really existed. + const end = endOfQuoted(sql, i, '"') + // An unterminated identifier swallows the rest of the file, so treat it + // as inert exactly like the unterminated literal below. Running the + // cursor to the end instead would exit the loop and report `false` — + // "live" — which is how the apostrophe bug destroyed a column in the + // first place, one branch over. + if (end > index) return true + i = end + } else if (sql[i] === "'") { + const end = endOfQuoted(sql, i, "'", isEscapeStringOpener(sql, i)) + // Unterminated, or the literal runs past `index`: `index` is inside a + // string literal, which is every bit as inert as a comment. + if (end > index) return true + i = end + } else { + i += 1 + } + } + return false +} + +/** + * The `$tag$` delimiter opening at `open`, or `undefined` when what sits there + * is just a `$`. The tag is empty (`$$`) or an SQL identifier (`$fn$`); a digit + * cannot start one, so a positional parameter (`$1`) is never mistaken for a + * dollar-quote opener. + */ +function dollarQuoteDelimiter(sql: string, open: number): string | undefined { + DOLLAR_QUOTE_OPEN_RE.lastIndex = open + return DOLLAR_QUOTE_OPEN_RE.exec(sql)?.[0] +} + +/** Sticky, so the scan never has to slice the file to test one position. */ +const DOLLAR_QUOTE_OPEN_RE = /\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/y + +/** + * Whether the `'` at `open` starts an `E''` escape-string, in which a backslash + * escapes the following character. Plain literals give backslash no special + * meaning under the default `standard_conforming_strings = on`. * - * Note: a copy of this lives in `stash` (`db/rewrite-migrations.ts`) - * because cli's `eql install --drizzle` uses the same fix. Both copies are - * tightly coupled to drizzle-kit's output format — if drizzle-kit changes, - * both need to be updated together. + * The character before the `E` must not be part of an identifier, so a column + * or type name that merely ends in `e` does not turn the literal after it into + * an escape-string. + */ +function isEscapeStringOpener(sql: string, open: number): boolean { + const prefix = sql[open - 1] + if (prefix !== 'E' && prefix !== 'e') return false + return !/[A-Za-z0-9_]/.test(sql[open - 2] ?? '') +} + +/** + * The index just past the `quote`-delimited token that opens at `open`, or + * `sql.length` when it is never closed. A doubled delimiter inside the token is + * an escaped one (`''`, `""`) and does not end it. + * + * With `backslashEscapes`, a `\` also escapes the next character — the `E''` + * form. Getting this wrong ends the literal EARLY, which shifts quote parity + * for the whole rest of the file and can make a commented-out ALTER downstream + * read as live (#772 review, finding 1). + */ +function endOfQuoted( + sql: string, + open: number, + quote: "'" | '"', + backslashEscapes = false, +): number { + let i = open + 1 + while (i < sql.length) { + if (backslashEscapes && sql[i] === '\\') { + i += 2 + } else if (sql[i] !== quote) { + i += 1 + } else if (sql[i + 1] === quote) { + i += 2 + } else { + return i + 1 + } + } + return sql.length +} + +/** A table reference, bare (`"users"`) or schema-qualified (`"app"."users"`). */ +const TABLE_REF = String.raw`"([^"]+)"(?:\."([^"]+)")?` + +/** + * An encrypted type in any of the {@link MANGLED_TYPE_FORMS}, pinned to end at a + * delimiter so a bare domain cannot match a prefix of a longer identifier. * - * The copies have DIVERGED as of #693: the `stash` one also matches the EQL v3 - * domain family (`eql_v3_*`) and two further mangled forms, and documents which - * drizzle-kit versions emit which. This copy stays v2-only because the wizard - * only ever scaffolds v2 columns. Port the v3 handling here if that changes. + * Two shapes {@link MANGLED_TYPE_FORMS} does not enumerate are folded in here so + * the encrypted index recognises them and the ADD+DROP+RENAME cannot drop their + * ciphertext: a domain in ANY quoted schema (`"app"."eql_v3_text_search"`, not + * just the literal `public` the mangled forms special-case), and a `[` in the + * trailing lookahead so an ARRAY of the domain (`public.eql_v3_text_search[]`) + * ends at a delimiter too. Both feed only the encrypted index — never the + * rewrite matcher — so the worst case of admitting one is a flagged statement, + * the same safe asymmetry the fail-closed rule already relies on. */ -const ALTER_COLUMN_TO_ENCRYPTED_RE = - /ALTER TABLE "([^"]+)"\s+ALTER COLUMN "([^"]+)"\s+SET DATA TYPE (?:"undefined"\.""public"\."eql_v2_encrypted""|"undefined"\."eql_v2_encrypted"|"public"\."eql_v2_encrypted"|eql_v2_encrypted)[^;]*;/gi +const ENCRYPTED_TYPE_REF = String.raw`(?:${MANGLED_TYPE_FORMS}|"[^"]+"\."(?:${ENCRYPTED_DOMAIN})")(?=[\s,;)[]|$)` + +/** `ALTER TABLE … ADD COLUMN "col" <encrypted>` — $1/$2 table, $3 column. */ +const ADD_ENCRYPTED_COLUMN_RE = new RegExp( + String.raw`ALTER TABLE\s+${TABLE_REF}\s+ADD COLUMN\s+(?:IF NOT EXISTS\s+)?"([^"]+)"\s+${ENCRYPTED_TYPE_REF}`, + 'gi', +) + +/** `ALTER TABLE … RENAME COLUMN "a" TO "b"` — $1/$2 table, $3 from, $4 to. */ +const RENAME_COLUMN_RE = new RegExp( + String.raw`ALTER TABLE\s+${TABLE_REF}\s+RENAME COLUMN\s+"([^"]+)"\s+TO\s+"([^"]+)"`, + 'gi', +) /** - * Replace in-place `ALTER COLUMN ... SET DATA TYPE eql_v2_encrypted` statements - * with an ADD + DROP + RENAME sequence. + * `CREATE TABLE … (` — $1/$2 table. The body's END is found by + * {@link endOfCreateTableBody} rather than by the matcher. * - * **Why this exists (CIP-2991, CIP-2994):** Postgres has no implicit cast from - * `text`/`numeric` to `eql_v2_encrypted`, so `ALTER COLUMN ... SET DATA TYPE - * eql_v2_encrypted` fails with `cannot cast type ... to eql_v2_encrypted`. - * The fix that works on both empty and non-empty tables is to add a new - * encrypted column, backfill it, drop the original, and rename the new - * column into place. For empty tables the UPDATE is a no-op and the - * sequence is effectively equivalent to DROP+ADD. + * This used to carry a lazy `([\s\S]*?)\)\s*;` body, which stopped at the FIRST + * `);` in the file — and that can sit inside a `--` comment or a string DEFAULT + * (`"id" text, -- pk (uuid);`). The body was then truncated and every column + * declared after that point vanished from both indexes, including an ENCRYPTED + * one — which is how a later domain change on a ciphertext column got past the + * already-encrypted guard (#772 review, finding 2). + */ +const CREATE_TABLE_HEAD_RE = new RegExp( + String.raw`CREATE TABLE\s+(?:IF NOT EXISTS\s+)?${TABLE_REF}\s*\(`, + 'gi', +) + +/** Candidate body terminators, filtered by {@link isInsideCommentOrString}. */ +const CREATE_TABLE_BODY_END_RE = /\)\s*;/g + +/** + * The offset of the `)` closing the CREATE TABLE body that opens at + * `bodyStart`, or `undefined` when the statement is never closed. * - * We only rewrite the statement — the actual encryption of existing rows has - * to happen in application code (via `encryptModel` from - * `@cipherstash/stack`), which is why the UPDATE is emitted as a guidance - * comment rather than real SQL. Running this migration against a populated - * table leaves the new column NULL until the app backfills it. + * Parens nested inside the body (`numeric(10,2)`, `CHECK (x > 0)`) are not + * followed by `;`, so requiring the terminator keeps them from matching. + */ +function endOfCreateTableBody( + sql: string, + bodyStart: number, +): number | undefined { + CREATE_TABLE_BODY_END_RE.lastIndex = bodyStart + for ( + let end = CREATE_TABLE_BODY_END_RE.exec(sql); + end !== null; + end = CREATE_TABLE_BODY_END_RE.exec(sql) + ) { + if (!isInsideCommentOrString(sql, end.index)) return end.index + } + return undefined +} + +/** `"col" <encrypted>` inside a CREATE TABLE body — $1 column. */ +const CREATE_TABLE_ENCRYPTED_COLUMN_RE = new RegExp( + String.raw`"([^"]+)"\s+${ENCRYPTED_TYPE_REF}`, + 'gi', +) + +/** + * A column name in a DECLARATION position — `"email" text`, + * `"email" "public"."eql_v3_text_search"`, `"amount" numeric(10, 2)`. + * + * The `\s+["a-z]` tail is what separates a declaration from a MENTION. Most + * mentions are followed by `)`, `,`, or an operator, which the tail alone + * already rejects: + * + * ```sql + * PRIMARY KEY ("id", "name") + * FOREIGN KEY ("org_id") REFERENCES "orgs"("id") + * ``` + * + * But a mention inside a predicate or a table-level constraint is often + * followed by whitespace and a SQL KEYWORD — which has exactly the same + * `\s+[a-z]` shape as a real declaration, and drizzle-kit emits both forms + * inside a `CREATE TABLE` (a `CONSTRAINT … CHECK (<user sql>)` body is + * user-authored, so the predicate form is reachable too): + * + * ```sql + * CHECK ("email" IS NOT NULL) + * CONSTRAINT "users_email_unique" UNIQUE ("email") + * ``` + * + * The negative lookahead rejects the keywords reachable this way: predicate + * keywords (`IS`, `IN`, `NOT`, `LIKE`, `ILIKE`, `BETWEEN`, `AND`, `OR`), + * ordering/collation (`COLLATE`, `ASC`, `DESC`, `NULLS`), and every + * table/column-constraint keyword (`UNIQUE`, `PRIMARY`, `FOREIGN`, `CHECK`, + * `EXCLUDE`, `REFERENCES`, `CONSTRAINT`, `USING`, `WITH`) — the last six also + * close the constraint-NAME case above, since Postgres always puts one of + * them immediately after a constraint's name. + * + * A real type token still matches because the lookahead is pinned to a word + * boundary (`\b`) right after each keyword, so it only rejects a keyword when + * that keyword is the WHOLE next word: `interval` and `inet` both start with + * `IN`'s letters, but their third character keeps them inside one word, so + * `\bIN\b` never matches there; the same reasoning clears `int`, + * `char`/`citext` against `CHECK`, and `eql_v2_encrypted`/`eql_v3_*` against + * `EXCLUDE`. + * + * Deliberately NOT pinned to the encrypted domains. A column the corpus + * declares but does not give an encrypted type is, by residue, plaintext — so + * the fail-closed rule needs no type classification and no SQL parsing, only + * the known encrypted list that {@link ENCRYPTED_TYPE_REF} already provides. + * + * **The residue claim depends on {@link ENCRYPTED_TYPE_REF} recognising every + * encrypted shape.** "By residue, plaintext" is only as good as the encrypted + * side's coverage: a declaration {@link ENCRYPTED_TYPE_REF} fails to recognise + * falls to the plaintext residue and gets rewritten. Two such shapes are now + * covered explicitly by {@link ENCRYPTED_TYPE_REF} — a domain in a non-`public` + * schema (`"email" "app"."eql_v3_text_search"`) and an array of the domain + * (`"email" public.eql_v3_text_search[]`) — because both name ciphertext the + * corpus can see, so rewriting them is the exact drop this rule exists to + * prevent. The dependency itself remains: any encrypted shape a future EQL + * install introduces must be added to {@link ENCRYPTED_TYPE_REF} or it too will + * fall to the plaintext residue. + * + * **Residue, accepted.** The lookahead is a fixed keyword list, not a parser: + * a predicate keyword it does not enumerate (`SIMILAR`, `ISNULL`, `NOTNULL`, + * `OVERLAPS`, `ON`, …) still lets a bare mention through, e.g. + * `CHECK ("email" SIMILAR TO '...')`, or a `REFERENCES "email" ON DELETE + * CASCADE` where `"email"` names the referenced TABLE rather than the column + * being declared — `"id" integer REFERENCES "email" ON DELETE CASCADE` inside + * a `CREATE TABLE` body still registers `email` as declared. That one only + * bites when a column shares a referenced table's name, so like the rest of + * this residue it is inert unless that mention's name exactly matches a + * DIFFERENT column that is both encrypted and genuinely undeclared anywhere + * else in the corpus — contrived, and strictly narrower than the gap this + * replaces. + */ +const DECLARED_COLUMN_RE = + /"([^"]+)"\s+(?!(?:IS|IN|NOT|LIKE|ILIKE|BETWEEN|AND|OR|COLLATE|ASC|DESC|NULLS|UNIQUE|PRIMARY|FOREIGN|CHECK|EXCLUDE|REFERENCES|CONSTRAINT|USING|WITH)\b)["a-z]/gi + +/** + * `ALTER TABLE … ADD COLUMN "col" <any type>` — $1/$2 table, $3 column. + * + * Needs no {@link DECLARED_COLUMN_RE}-style keyword lookahead: the token + * right after an ADD COLUMN's column name is always its type, so no SQL + * keyword can occupy that position. + */ +const ADD_COLUMN_RE = new RegExp( + String.raw`ALTER TABLE\s+${TABLE_REF}\s+ADD COLUMN\s+(?:IF NOT EXISTS\s+)?"([^"]+)"\s+["a-z]`, + 'gi', +) + +/** Splits a `TABLE_REF` capture pair into its schema and table halves. */ +function tableOf( + first: string, + second: string | undefined, +): { schema?: string; table: string } { + // When schema-qualified (`"app"."users"`) the first capture is the schema and + // the second is the table; otherwise the first is the table. + return second === undefined + ? { table: first } + : { schema: first, table: second } +} + +/** + * Identity of a column across the corpus, for {@link indexColumnDeclarations}. + * + * `public` and no schema at all are the SAME table: Postgres resolves an + * unqualified name through `search_path`, which starts at `public`. The two + * spellings coexist in one corpus routinely — drizzle-kit emits unqualified, + * while hand-written SQL and this sweep's own {@link renderSafeAlter} output + * are qualified. Keying on the literal text split one column across two keys, + * so a column already encrypted under one spelling looked plaintext when + * altered under the other and the ADD+DROP+RENAME dropped its ciphertext + * (#772 review, finding 4). + * + * Only the IMPLICIT schema collapses. `"app"."users"` is a genuinely different + * table from `"users"` and keeps its own key. No case folding either: + * `TABLE_REF` matches quoted identifiers only, and those are case-sensitive in + * Postgres — `"PUBLIC"` really is a different schema from `"public"`. + */ +function columnKey(table: string, column: string, schema?: string): string { + return JSON.stringify([ + schema === 'public' ? '' : (schema ?? ''), + table, + column, + ]) +} + +/** What the migration corpus says about the columns it mentions. */ +interface ColumnIndex { + /** Columns the corpus gives an ENCRYPTED type. Rewriting one drops ciphertext. */ + encrypted: Set<string> + /** Columns the corpus DECLARES at all, whatever type it gives them. */ + declared: Set<string> +} + +/** + * Index what the migration corpus knows about each column, so the rewrite can + * tell the change it exists for (plaintext → encrypted) from the two it must + * never make: encrypted → encrypted, and a change to a column it knows nothing + * about. + * + * **Why `encrypted` (#772 review, W-3):** the strict matcher captures only the + * TARGET type. A column whose encrypted domain merely changes (`types.TextEq` → + * `types.TextSearch`) matches just as well as a plaintext column, and the + * ADD+DROP+RENAME then drops a column full of CIPHERTEXT — with no plaintext + * left anywhere to backfill from, so unlike the plaintext case the data is not + * recoverable from the application at all. + * + * **Why `declared` (A-2):** absence from `encrypted` is not evidence of + * plaintext. It is evidence of nothing. The sweep runs per directory, and the + * shipped wizard default scans three of them, so a column's `CREATE TABLE` can + * simply live somewhere this sweep never reads — the column is then rewritten + * on an assumption, and the ADD+DROP+RENAME drops live ciphertext. Requiring a + * POSITIVE declaration makes the unknown case a flagged statement instead. + * + * Because the encrypted side is matched against the known domain list, the + * plaintext side needs no type classification: a column the corpus declares but + * does not give an encrypted type is plaintext by residue. + * + * The index is corpus-wide rather than ordered by migration: over-detecting + * "encrypted" costs a flagged statement the user handles by hand, while + * under-detecting costs irrecoverable ciphertext. That asymmetry is why the + * two per-column scans inside a `CREATE TABLE` body are deliberately + * inconsistent about comments (see the comment at the loops themselves) — + * do not "fix" that inconsistency, it is the point. + */ +function indexColumnDeclarations(contents: readonly string[]): ColumnIndex { + const encrypted = new Set<string>() + const declared = new Set<string>() + + for (const sql of contents) { + for (const created of sql.matchAll(CREATE_TABLE_HEAD_RE)) { + if (isInsideCommentOrString(sql, created.index)) continue + const { schema, table } = tableOf(created[1], created[2]) + const bodyStart = created.index + created[0].length + const bodyEnd = endOfCreateTableBody(sql, bodyStart) + // Never closed — treat the statement as absent rather than index a body + // that runs to the end of the file. + if (bodyEnd === undefined) continue + const body = sql.slice(bodyStart, bodyEnd) + + // The body is scanned as its own document: a `--` earlier in it comments + // out the rest of that line, and a CREATE inside a block comment or a + // string literal was already skipped above. + // + // Asymmetric on purpose. ENCRYPTED does NOT re-check comments here: a + // commented-out encrypted column inside an otherwise live CREATE TABLE + // still counts, exactly as it did before this sweep learned to declare + // anything — over-detecting "encrypted" only costs a flagged statement. + // DECLARED DOES re-check: a commented-out plaintext line never ran, so + // it must not count as a declaration — over-detecting "declared" is + // what would put a truly-undeclared, possibly already-encrypted column + // back on the fail-open path. + for (const column of body.matchAll(CREATE_TABLE_ENCRYPTED_COLUMN_RE)) { + encrypted.add(columnKey(table, column[1], schema)) + } + + for (const column of body.matchAll(DECLARED_COLUMN_RE)) { + if (isInsideCommentOrString(body, column.index)) continue + declared.add(columnKey(table, column[1], schema)) + } + } + + for (const added of sql.matchAll(ADD_ENCRYPTED_COLUMN_RE)) { + if (isInsideCommentOrString(sql, added.index)) continue + const { schema, table } = tableOf(added[1], added[2]) + encrypted.add(columnKey(table, added[3], schema)) + } + + for (const added of sql.matchAll(ADD_COLUMN_RE)) { + if (isInsideCommentOrString(sql, added.index)) continue + const { schema, table } = tableOf(added[1], added[2]) + declared.add(columnKey(table, added[3], schema)) + } + + // A rename carries the column's type with it — and `<col>__cipherstash_tmp` + // renamed onto the real name is exactly what a previous sweep of this very + // directory emitted. Run after ADD so that tmp column is already indexed. + for (const renamed of sql.matchAll(RENAME_COLUMN_RE)) { + if (isInsideCommentOrString(sql, renamed.index)) continue + const { schema, table } = tableOf(renamed[1], renamed[2]) + const from = columnKey(table, renamed[3], schema) + const to = columnKey(table, renamed[4], schema) + if (encrypted.has(from)) encrypted.add(to) + if (declared.has(from)) declared.add(to) + } + } + + return { encrypted, declared } +} + +/** Why a recognised ALTER-to-encrypted statement was left alone. */ +export type SkipReason = + /** Outside the strict matcher — hand-authored `USING`, or an unknown form. */ + | 'unrecognised-form' + /** The column already holds an encrypted domain; rewriting drops ciphertext. */ + | 'already-encrypted' + /** The corpus never declares the column, so its current type is unknown. */ + | 'source-unknown' + +/** A statement the sweep recognised as ALTER-to-encrypted but did NOT rewrite. */ +export interface SkippedAlter { + /** Absolute path of the migration file the statement lives in. */ + file: string + /** The offending statement, verbatim (trimmed), for the user to review. */ + statement: string + /** Why it was left alone — the caller turns this into user-facing guidance. */ + reason: SkipReason +} + +/** + * One-line explanation of a {@link SkipReason}, for the CLI/wizard to print + * next to the statement. Lives here so every caller says the same thing — the + * three reasons need very different action from the user, and a single generic + * "could not rewrite automatically" hides that. + */ +export function describeSkipReason(reason: SkipReason): string { + switch (reason) { + case 'already-encrypted': + return "the column is ALREADY encrypted, so the ADD+DROP+RENAME rewrite would DROP the ciphertext with no plaintext left to backfill from. Changing an encrypted column's domain changes its index terms, so the data must be re-encrypted through the staged `stash encrypt` lifecycle" + case 'unrecognised-form': + return 'it falls outside the strict matcher (a hand-authored `SET DATA TYPE ... USING ...`, or a drizzle-kit form the sweep does not recognise) and an in-place cast to an encrypted domain fails at migrate time' + case 'source-unknown': + return "the sweep could not find where this column was declared in this migration directory, so it cannot tell a plaintext column (safe to rewrite) from one that already holds ciphertext (where the rewrite would DROP it). Usually the column's `CREATE TABLE` / `ADD COLUMN` lives in a different directory, or the migration history was squashed. Check the column's current type in the database: if it is plaintext and the table is empty, apply the ADD/DROP/RENAME by hand; if it already holds ciphertext, use the staged `stash encrypt` lifecycle instead" + } +} + +/** Outcome of a sweep: the files rewritten, and near-misses left for review. */ +export interface RewriteResult { + /** Absolute paths of files whose unsafe ALTER COLUMN(s) were rewritten. */ + rewritten: string[] + /** Near-miss statements the strict matcher passed over — flag, don't guess. */ + skipped: SkippedAlter[] +} + +/** + * Replace in-place `ALTER COLUMN ... SET DATA TYPE <encrypted domain>` + * statements with an ADD + DROP + RENAME sequence. + * + * **Why this exists (CIP-2991, CIP-2994, #693):** Postgres has no implicit cast + * from `text`/`numeric` to an encrypted domain, so `ALTER COLUMN ... SET DATA + * TYPE eql_v2_encrypted` (or any `eql_v3_*` domain) fails at migrate time with + * `cannot cast type ... to <domain>`. This applies equally to the single EQL v2 + * type and the whole EQL v3 concrete-domain family. + * + * The rewrite is an ADD+DROP+RENAME, which is **equivalent to DROP+ADD**: it + * makes the column type valid but does NOT preserve the column's data. It is + * therefore safe ONLY on an EMPTY table. On a populated table the new column + * starts NULL and the original is dropped in the same migration, so the + * plaintext is destroyed. The commented UPDATE is a placeholder that can never + * become real SQL (the encrypted value is the EQL envelope produced by ZeroKMS + * via the client — there is no expression Postgres can evaluate to fill it), so + * a populated table must instead use the staged `stash encrypt` lifecycle + * (add → backfill via `@cipherstash/stack`'s `encryptModel` → cutover → drop), + * which keeps both columns alive across deploys. Each rewritten file carries a + * header comment saying exactly this. + * + * Returns {@link RewriteResult}: the files rewritten, plus `skipped` statements + * left for a human — ones outside the strict matcher (a hand-authored + * `SET DATA TYPE … USING …;`, or a future drizzle-kit form); ones targeting a + * column that is ALREADY encrypted, where the rewrite would drop ciphertext; + * and ones targeting a column the corpus never DECLARES anywhere, where the + * rewrite would be guessing at a type it cannot see (likely the most common + * skip on a real corpus, since the sweep only ever reads part of the + * migration history). All three are left untouched on disk and surfaced + * non-fatally so the caller can tell the user to review them, rather than + * silently shipping broken SQL or destroying data. Statements sitting inside a + * SQL comment — or inside a single-quoted string literal, where they are data + * rather than SQL — are inert and are neither rewritten nor reported. */ export async function rewriteEncryptedAlterColumns( outDir: string, options: { skip?: string } = {}, -): Promise<string[]> { - const entries = await readdir(outDir).catch(() => []) +): Promise<RewriteResult> { + const entries = await readdir(outDir).catch( + (error: NodeJS.ErrnoException) => { + // A missing directory is simply nothing to sweep. Anything else — EACCES + // above all — is a sweep that did NOT happen, and the caller reports it + // rather than letting the user believe their migrations were checked. + if (error.code === 'ENOENT') return [] as string[] + throw error + }, + ) const rewritten: string[] = [] + const skipped: SkippedAlter[] = [] + const seen = new Set<string>() - for (const entry of entries) { - if (!entry.endsWith('.sql')) continue + /** Record a skip once — the strict pass and the broad scan can both find it. */ + const skip = (file: string, statement: string, reason: SkipReason): void => { + // Keyed on collapsed whitespace: the two passes trim the same statement + // by slightly different rules, and the strict pass runs first so its + // more specific reason is the one kept. + const key = `${file} :: ${statement.replace(/\s+/g, ' ')}` + if (seen.has(key)) return + seen.add(key) + skipped.push({ file, statement, reason }) + } + + const sqlFiles = entries.filter((entry) => entry.endsWith('.sql')).sort() + const contents = new Map<string, string>() + for (const entry of sqlFiles) { const filePath = join(outDir, entry) - if (options.skip && filePath === options.skip) continue + contents.set(filePath, await readFile(filePath, 'utf-8')) + } + + // Built from the WHOLE corpus, including `options.skip`: a column's current + // type comes from the migrations that ran before this one, not just the files + // we are allowed to edit. + const { encrypted: encryptedColumns, declared: declaredColumns } = + indexColumnDeclarations([...contents.values()]) - const original = await readFile(filePath, 'utf-8') - if (!ALTER_COLUMN_TO_ENCRYPTED_RE.test(original)) continue + for (const [filePath, original] of contents) { + if (options.skip && filePath === options.skip) continue // Reset the regex's lastIndex — it's stateful on /g ALTER_COLUMN_TO_ENCRYPTED_RE.lastIndex = 0 const updated = original.replace( ALTER_COLUMN_TO_ENCRYPTED_RE, - (_match, table: string, column: string) => renderSafeAlter(table, column), + ( + match: string, + first: string, + second: string | undefined, + column: string, + mangledType: string, + offset: number, + ) => { + // Commented-out SQL never runs, and a multi-line replacement would only + // inherit the `-- ` on its first line — leaving the rest live. + if (isInsideCommentOrString(original, offset)) return match + + const { schema, table } = tableOf(first, second) + + // Already encrypted: the ADD+DROP+RENAME would drop the ciphertext and + // there is no plaintext left to backfill from. Flag, never guess. + if (encryptedColumns.has(columnKey(table, column, schema))) { + skip(filePath, match.trim(), 'already-encrypted') + return match + } + + // Fail closed. Absence from `declaredColumns` does not mean the column + // is plaintext — it means the corpus never said. The declaration can + // sit in a directory this sweep does not read (the wizard ships with + // three candidates and indexes each separately), so rewriting on the + // assumption is how a live `DROP COLUMN` reaches a populated — possibly + // already-encrypted — column. Flag it and let the user look. + if (!declaredColumns.has(columnKey(table, column, schema))) { + skip(filePath, match.trim(), 'source-unknown') + return match + } + + const domain = DOMAIN_RE.exec(mangledType)?.[0]?.toLowerCase() + // Unreachable — the outer regex only matches when a domain is present — + // but leave the statement alone rather than emit a broken rewrite. + if (!domain) return match + + // This statement converts the column, so from here on in the corpus it + // holds CIPHERTEXT. `indexColumnDeclarations` cannot know that: it + // reads CREATE TABLE, ADD COLUMN and RENAME, never the strict matcher's + // own target. Without this, a corpus carrying an earlier conversion + // (`... SET DATA TYPE eql_v2_encrypted` from a stack version that + // predates this sweep) leaves the column looking plaintext, and the + // NEXT domain change drops the ciphertext — `declared` cannot catch it, + // because the column really is declared, as plaintext, by the original + // CREATE TABLE. + // + // Recorded here rather than in the corpus-wide index on purpose: the + // index has no order, so it would flag THIS conversion — the legitimate + // plaintext -> encrypted one — as already-encrypted too. Files are + // walked in sorted order and matches within a file in source order, so + // "already converted" means "converted by a statement that runs before + // this one". + encryptedColumns.add(columnKey(table, column, schema)) + return renderSafeAlter(table, column, domain, schema) + }, ) if (updated !== original) { await writeFile(filePath, updated, 'utf-8') rewritten.push(filePath) } + + // Broad secondary scan on the POST-rewrite content: anything still carrying + // `SET DATA TYPE` near an eql_v2/eql_v3 token slipped past the strict + // matcher. Flag it — non-fatally — rather than leave the user shipping SQL + // that fails at migrate time. + for (const nearMiss of updated.matchAll(NEAR_MISS_RE)) { + const statement = trimStatementPreamble(nearMiss[0]) + // Anchor the comment test on the `SET DATA TYPE` itself: the match starts + // at the previous `;`, so its own offset sits before any preamble. + const keyword = nearMiss[0].search(/\bSET\s+DATA\s+TYPE\b/i) + if ( + isInsideCommentOrString(updated, nearMiss.index + Math.max(keyword, 0)) + ) { + continue + } + skip(filePath, statement, 'unrecognised-form') + } + } + + return { rewritten, skipped } +} + +// #region wizard-only — deliberately has no counterpart in +// packages/cli/src/commands/db/rewrite-migrations.ts. Everything OUTSIDE this +// region is mirrored there byte-for-byte and is checked by +// scripts/__tests__/rewriter-copies-in-sync.test.mjs. Only the wizard sweeps +// several candidate directories; both CLI call sites pass one explicit --out. +/** One candidate migration directory's outcome from {@link sweepMigrationDirs}. */ +export interface DirRewriteResult extends RewriteResult { + /** The candidate directory as supplied (relative to `cwd`), for reporting. */ + dir: string + /** Set when this directory's sweep threw; the sweep continues regardless. */ + error?: string + /** + * Set when the directory holds `.sql` files but no drizzle-kit journal, so + * nothing was swept. Reported rather than silent: a genuine drizzle output + * directory whose `meta/` was deleted would otherwise look clean. + */ + notDrizzleOutput?: true +} + +/** + * Whether `abs` is a drizzle-kit output directory. + * + * drizzle-kit writes `meta/_journal.json` there on the first `generate` and + * maintains it for every migration after; nothing else in the candidate list's + * ecosystem does. Knex, node-pg-migrate and Flyway all track state in the + * database, not in a sibling file. + */ +function isDrizzleOutputDir(abs: string): boolean { + return existsSync(join(abs, 'meta', '_journal.json')) +} + +/** Whether the directory holds anything this sweep could have acted on. */ +async function holdsSqlFiles(abs: string): Promise<boolean> { + try { + const entries = await readdir(abs) + return entries.some((entry) => entry.endsWith('.sql')) + } catch { + return false + } +} + +/** + * Sweep every candidate migration directory under `cwd`, rewriting each one's + * unsafe ALTER-to-encrypted statements. Directories that don't exist are + * skipped; the returned array carries one entry per directory that did. + * + * **Why every directory, not just the first:** a project's drizzle-kit `out` + * directory is not discoverable from here, so the caller passes a candidate + * list. Stopping at the first candidate that merely *exists* means an empty or + * already-rewritten `drizzle/` sitting next to the project's real `migrations/` + * silently leaves those migrations unrepaired — they then fail at migrate time + * with the very `cannot cast type ...` error this function exists to prevent. + * + * Sweeping all of them is safe because the rewrite is idempotent: a rewritten + * statement no longer contains `SET DATA TYPE`, so neither + * {@link ALTER_COLUMN_TO_ENCRYPTED_RE} nor {@link NEAR_MISS_RE} can match it a + * second time. Two candidates resolving to the same directory (via a symlink) + * therefore cost a redundant read, not a double transform. + * + * A directory that throws mid-sweep is reported via `error` rather than + * aborting — one unreadable candidate must not strand the others. + * + * **Why only drizzle-kit output directories:** the candidate list is a guess, + * and two of its three entries are generic names that Knex, node-pg-migrate, + * Flyway and hand-rolled psql also use. This sweep emits `DROP COLUMN`, so + * rewriting a directory it was never pointed at is the worst thing it can do — + * and the fail-closed `declared` rule is no defence there, because a real + * migration history declares its own columns. Requiring the `meta/_journal.json` + * that drizzle-kit maintains keeps the reach to directories drizzle-kit owns + * (#772 review, finding 5). + */ +export async function sweepMigrationDirs( + cwd: string, + dirs: readonly string[], +): Promise<DirRewriteResult[]> { + const results: DirRewriteResult[] = [] + + for (const dir of dirs) { + const abs = resolve(cwd, dir) + if (!existsSync(abs)) continue + + if (!isDrizzleOutputDir(abs)) { + // Only worth surfacing when there was something here to act on; an empty + // or non-SQL directory is noise. + if (await holdsSqlFiles(abs)) { + results.push({ + dir, + rewritten: [], + skipped: [], + notDrizzleOutput: true, + }) + } + continue + } + + try { + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(abs) + results.push({ dir, rewritten, skipped }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + results.push({ dir, rewritten: [], skipped: [], error: message }) + } } - return rewritten + return results } +// #endregion wizard-only -function renderSafeAlter(table: string, column: string): string { +/** + * The rewrite is identical for v2 and v3, and the ADD+DROP+RENAME sequence is + * equivalent to DROP+ADD: it makes the column type valid but does NOT preserve + * the column's data. It is therefore safe only on an EMPTY table. On a populated + * table the new column starts NULL and the old one is dropped in the same + * migration, so the plaintext is destroyed. + * + * The commented UPDATE is only a placeholder — it can never become real SQL. The + * encrypted value is the EQL envelope produced by ZeroKMS via the client + * (`encryptModel` / `bulkEncryptModels`); there is no expression Postgres can + * evaluate to fill it. (v3 stores that envelope as jsonb rather than v2's + * composite, but this is equally true on both surfaces.) + * + * So the guidance does NOT tell the user to backfill and run this migration — + * that would still lose data on cutover. It points a populated table at the + * staged `stash encrypt` lifecycle (add → backfill → cutover → drop), which + * keeps both columns alive across deploys. + */ +function renderSafeAlter( + table: string, + column: string, + domain: string, + schema?: string, +): string { const tmp = `${column}__cipherstash_tmp` + // Preserve the schema qualifier drizzle-kit emitted for pgSchema() tables so + // the rewritten statements target the same object. + const qualifiedTable = schema ? `"${schema}"."${table}"` : `"${table}"` return [ '-- Rewritten by @cipherstash/wizard: in-place ALTER COLUMN cannot cast to', - `-- eql_v2_encrypted. If "${table}" already has rows, backfill the new`, - "-- column via @cipherstash/stack's encryptModel in application code BEFORE", - '-- running this migration in production. Empty tables are safe as-is.', - `ALTER TABLE "${table}" ADD COLUMN "${tmp}" "public"."eql_v2_encrypted";`, - `-- UPDATE "${table}" SET "${tmp}" = /* encrypted value for ${column} */ NULL;`, - `ALTER TABLE "${table}" DROP COLUMN "${column}";`, - `ALTER TABLE "${table}" RENAME COLUMN "${tmp}" TO "${column}";`, + `-- ${domain}. This ADD+DROP+RENAME equals DROP+ADD and is safe ONLY if`, + `-- ${qualifiedTable} is empty. On a populated table it DESTROYS existing "${column}"`, + '-- data (the new column starts NULL) — do NOT run it there. Use the staged', + "-- `stash encrypt` path instead: add -> backfill via @cipherstash/stack's", + '-- encryptModel in application code -> cutover -> drop.', + '-- NOTE: constraints, defaults, and indexes on the original column are NOT', + '-- carried over by this ADD/DROP/RENAME — re-add any NOT NULL, DEFAULT,', + '-- UNIQUE, or index definitions manually.', + // The domain is emitted as `"public"."<domain>"` unconditionally: EQL + // installs its domains into `public` (both `stash eql install` and the + // adapters' baseline migrations do), and the qualifier makes the rewrite + // independent of the session `search_path`. It is an ASSUMPTION, not + // something read back from the matched SQL — the `schema` capture above is + // the TABLE's schema (from a pgSchema() table) and says nothing about where + // the domain lives. If EQL ever supports installing into a non-`public` + // schema, this needs the install schema threaded in, here and in the + // sibling `packages/cli/src/commands/db/rewrite-migrations.ts`. + `ALTER TABLE ${qualifiedTable} ADD COLUMN "${tmp}" "public"."${domain}";`, + `-- UPDATE ${qualifiedTable} SET "${tmp}" = /* encrypted value for ${column} */ NULL`, + '--> statement-breakpoint', + `ALTER TABLE ${qualifiedTable} DROP COLUMN "${column}";`, + '--> statement-breakpoint', + `ALTER TABLE ${qualifiedTable} RENAME COLUMN "${tmp}" TO "${column}";`, ].join('\n') } diff --git a/packages/wizard/src/tools/wizard-tools.ts b/packages/wizard/src/tools/wizard-tools.ts index 3a589b7bf..f60673aa5 100644 --- a/packages/wizard/src/tools/wizard-tools.ts +++ b/packages/wizard/src/tools/wizard-tools.ts @@ -149,6 +149,25 @@ interface DbColumn { isEqlEncrypted: boolean } +/** + * Is a Postgres domain-type (`udt_name`) an EQL-encrypted column? + * + * Used purely for DETECTION — telling the agent "this column is already + * encrypted, don't scaffold over it". It therefore recognises BOTH generations: + * the legacy single `eql_v2_encrypted` type AND the EQL v3 concrete-domain + * family (`eql_v3_text_search`, `eql_v3_integer_ord`, …). The wizard now only + * *emits* v3, but a project may already carry v2-encrypted columns whose + * ciphertext stays valid — misreporting them as plaintext would let the agent + * clobber real encrypted data. + * + * The `eql_v3_` prefix (trailing underscore included) matches the convention in + * `@cipherstash/migrate`'s `classifyEqlDomain` — a bare `eql_v3` would also + * claim a hypothetical future major like `eql_v30_*`. + */ +function isEqlEncryptedDomain(udtName: string): boolean { + return udtName === 'eql_v2_encrypted' || udtName.startsWith('eql_v3_') +} + interface DbTable { tableName: string columns: DbColumn[] @@ -183,7 +202,7 @@ export async function introspectDatabase( columnName: row.column_name, dataType: row.data_type, udtName: row.udt_name, - isEqlEncrypted: row.udt_name === 'eql_v2_encrypted', + isEqlEncrypted: isEqlEncryptedDomain(row.udt_name), }) tableMap.set(row.table_name, cols) } diff --git a/packages/wizard/tsconfig.json b/packages/wizard/tsconfig.json index 1f60894ec..7cc1059cb 100644 --- a/packages/wizard/tsconfig.json +++ b/packages/wizard/tsconfig.json @@ -7,6 +7,17 @@ "allowJs": true, "esModuleInterop": true, "moduleResolution": "bundler", + + // `@cipherstash/auth` uses conditional exports — `node` picks `index.d.ts` + // (the full surface, including `AutoStrategy`), `default` picks + // `wasm-types.d.ts` (which has no `AutoStrategy`). Bundler resolution does + // not include `node` by default, so without this the wizard's three + // `auth.AutoStrategy` call sites fail to typecheck even though they run + // fine — the wizard is a Node CLI and always loads the `node` branch. + // Matches `packages/stack`, `packages/prisma-next`, `packages/test-kit`, + // `packages/stack-drizzle` and `packages/stack-supabase`. + "customConditions": ["node"], + "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "noEmit": true, @@ -19,5 +30,10 @@ "paths": { "@/*": ["./src/*"] } - } + }, + // The `typecheck` gate must compile source, not build output. TypeScript's + // default `exclude` covers `node_modules` but NOT `dist`, and specifying + // `exclude` replaces that default — hence both. Enforced by + // `pnpm run lint:typecheck-scope`. + "exclude": ["dist", "node_modules"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 105cb6953..8a666f68e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,9 +86,6 @@ importers: e2e: dependencies: - '@cipherstash/protect': - specifier: workspace:* - version: link:../packages/protect '@cipherstash/stack': specifier: workspace:* version: link:../packages/stack @@ -120,12 +117,12 @@ importers: '@cipherstash/stack-drizzle': specifier: workspace:* version: link:../../packages/stack-drizzle - '@cipherstash/stack-supabase': - specifier: workspace:* - version: link:../../packages/stack-supabase dotenv: specifier: ^17.4.2 version: 17.4.2 + drizzle-orm: + specifier: ^0.45.2 + version: 0.45.2(@types/pg@8.20.0)(gel@2.2.0)(mysql2@3.16.0)(pg@8.22.0)(postgres@3.4.9) pg: specifier: 8.22.0 version: 8.22.0 @@ -221,6 +218,9 @@ importers: specifier: ^8.22.0 version: 8.22.0 devDependencies: + '@cipherstash/test-kit': + specifier: workspace:* + version: link:../test-kit '@types/node': specifier: ^22.20.1 version: 22.20.1 @@ -465,97 +465,6 @@ importers: specifier: catalog:repo version: 3.2.7(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.0)(yaml@2.9.0) - packages/protect: - dependencies: - '@byteslice/result': - specifier: ^0.2.0 - version: 0.2.0 - '@cipherstash/protect-ffi': - specifier: 0.23.0 - version: 0.23.0 - '@cipherstash/schema': - specifier: workspace:* - version: link:../schema - '@stricli/core': - specifier: ^1.2.9 - version: 1.2.9 - dotenv: - specifier: 17.4.2 - version: 17.4.2 - zod: - specifier: ^3.25.76 - version: 3.25.76 - devDependencies: - '@supabase/supabase-js': - specifier: ^2.110.2 - version: 2.110.2 - execa: - specifier: ^9.5.2 - version: 9.6.1 - json-schema-to-typescript: - specifier: ^15.0.2 - version: 15.0.4 - postgres: - specifier: ^3.4.7 - version: 3.4.9 - tsup: - specifier: catalog:repo - version: 8.5.1(jiti@2.7.0)(postcss@8.5.14)(tsx@4.23.0)(typescript@5.9.3)(yaml@2.9.0) - tsx: - specifier: catalog:repo - version: 4.23.0 - typescript: - specifier: catalog:repo - version: 5.9.3 - vitest: - specifier: catalog:repo - version: 3.2.7(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.0)(yaml@2.9.0) - optionalDependencies: - '@rollup/rollup-linux-x64-gnu': - specifier: 4.62.2 - version: 4.62.2 - - packages/protect-dynamodb: - dependencies: - '@byteslice/result': - specifier: ^0.2.0 - version: 0.2.0 - devDependencies: - '@cipherstash/protect': - specifier: workspace:* - version: link:../protect - dotenv: - specifier: ^17.4.2 - version: 17.4.2 - tsup: - specifier: catalog:repo - version: 8.5.1(jiti@2.7.0)(postcss@8.5.14)(tsx@4.23.0)(typescript@5.9.3)(yaml@2.9.0) - tsx: - specifier: catalog:repo - version: 4.23.0 - typescript: - specifier: catalog:repo - version: 5.9.3 - vitest: - specifier: catalog:repo - version: 3.2.7(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.0)(yaml@2.9.0) - - packages/schema: - dependencies: - zod: - specifier: ^3.25.76 - version: 3.25.76 - devDependencies: - tsup: - specifier: catalog:repo - version: 8.5.1(jiti@2.7.0)(postcss@8.5.14)(tsx@4.23.0)(typescript@5.9.3)(yaml@2.9.0) - typescript: - specifier: catalog:repo - version: 5.9.3 - vitest: - specifier: catalog:repo - version: 3.2.7(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.0)(yaml@2.9.0) - packages/stack: dependencies: '@byteslice/result': @@ -1076,69 +985,36 @@ packages: '@cipherstash/eql@3.0.2': resolution: {integrity: sha512-E85o0aoOqgCW6RReLtJ0YLh/ExRlmDJo7LlJGpWPoMTVaw+CW8o11DJ4oJIF1vFtuxSVxNULuPzzBuVmpTvvcA==} - '@cipherstash/protect-ffi-darwin-arm64@0.23.0': - resolution: {integrity: sha512-DhkKC+trOfk3RLDvPXqGsrpWdVnLAMEVLUI59OuR9tdTcJeiABtbQx8VaXdbzvNxnbkoDnOqbFRE5D11Z7nerQ==} - cpu: [arm64] - os: [darwin] - '@cipherstash/protect-ffi-darwin-arm64@0.30.0': resolution: {integrity: sha512-MbUfH0em2ysQxEV8+ed6442Q/EPQbAh5p17p1p4+JLUgbQJwPvxP8gm0yjeVrYBuGnZodsMqZwgOgEGt7360Ig==} cpu: [arm64] os: [darwin] - '@cipherstash/protect-ffi-darwin-x64@0.23.0': - resolution: {integrity: sha512-YuEn2RDHOaj9s8qDIX9cpQuBmsN2SZp/RjiNX72LxhV7JEDJuLSt0ySrl+k6MHoLiZotjkp7I1u6tq3vuLCC0Q==} - cpu: [x64] - os: [darwin] - '@cipherstash/protect-ffi-darwin-x64@0.30.0': resolution: {integrity: sha512-GD2RXtjLvQaxWOPq2kbZEoNKTYWpDNTPQJ/tb9djpJF4RaR15g/jYhvk7Nqe2v3o6gre5RJBUpsaTQqC+G+L9A==} cpu: [x64] os: [darwin] - '@cipherstash/protect-ffi-linux-arm64-gnu@0.23.0': - resolution: {integrity: sha512-I1kID2JqWnJUd0VHzNQo4gxeOAhEgzeXg3Fn0iDHnGKy+HDHd7+t/qEei9YLrV0wAXDnDFRhXXWRRVs8CxDzZA==} - cpu: [arm64] - os: [linux] - '@cipherstash/protect-ffi-linux-arm64-gnu@0.30.0': resolution: {integrity: sha512-R/DWCDQDDx/hRksRueJLvqxyQCSpGOZIummKqcVvBNkIhF/6Aos1SZ+zXTHAZ4gvBlfu3ovnRfMMI6eAstZDog==} cpu: [arm64] os: [linux] - '@cipherstash/protect-ffi-linux-x64-gnu@0.23.0': - resolution: {integrity: sha512-n4aCDK0os4iY1BQIHVVUBgt8WnfIb8R3gLXTRrTkMVug0dcoQ0ZZaL5ltIUgFGJG4bvfW8+7zWLRZ51CZkqKsQ==} - cpu: [x64] - os: [linux] - '@cipherstash/protect-ffi-linux-x64-gnu@0.30.0': resolution: {integrity: sha512-uLi9kiKkJ9jUki75hgvLnDsDrJOj0RtZ8G0fVaKl4nnhZjCIVqpkn0EMlf0GOq2Bj8WiGSnca7kTGrhVkvgY9w==} cpu: [x64] os: [linux] - '@cipherstash/protect-ffi-linux-x64-musl@0.23.0': - resolution: {integrity: sha512-62WG6ayFJ/1+M7W/AWGEDskLo6aHtr8PFoHoXkSTdhWf29RPb4+yU1pPNYVCitVWB1sdGs+lXSO6MFD4N6IIXw==} - cpu: [x64] - os: [linux] - '@cipherstash/protect-ffi-linux-x64-musl@0.30.0': resolution: {integrity: sha512-ZrPyxc+qi9u9LsyGYbdqoi6oMPeBBx+E+/uZuAzVtp115k8CsI8xlcrmNswQ6Z74EgvAUfLb1ykTn5/P9Ge0rw==} cpu: [x64] os: [linux] - '@cipherstash/protect-ffi-win32-x64-msvc@0.23.0': - resolution: {integrity: sha512-z+jErHcPw1RwiwhSqqx/QzKqkk06gulh6YJl4TlSBPlJPjhR30TEcxQpQ2zf7kuv86JqsBRHv8UazLNePSiEww==} - cpu: [x64] - os: [win32] - '@cipherstash/protect-ffi-win32-x64-msvc@0.30.0': resolution: {integrity: sha512-xew3mH+jf9RlfuIjXQ7KFWTs44Wjur//Ed1U48yFK3zmsyzkZGYVwfN8zieKpKgKqvj844s5x/0kvFXtrJDB0A==} cpu: [x64] os: [win32] - '@cipherstash/protect-ffi@0.23.0': - resolution: {integrity: sha512-Ca8MKLrrumC561VoPDOhuUZcF8C8YenqO1Ig9hSJSRUB+jFeIJXeyn7glExsvKYWtxOx/pRub9FV8A0RyuPHMg==} - '@cipherstash/protect-ffi@0.30.0': resolution: {integrity: sha512-Xh8X/71ZOW6B6iEKPQlG4KrDgsKYZBw29ohsFnmMKOaTWVR7EodCE6MpSRDJ4uc+zYftp3QWsvUDEFz9gDpg6w==} @@ -2107,9 +1983,6 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@stricli/core@1.2.9': - resolution: {integrity: sha512-vKniyS+0GtarIaUpMRMQ5+Ck9mXeaBqdN/7otznkBTWU0EXfkMJAHt2QdOCmOudR9eciQZQf09DmYm14yfPWzw==} - '@supabase/auth-js@2.110.2': resolution: {integrity: sha512-Qj7a6EDP+AMMQFWqGv+qFa8r6re//dk+qQI5bA0KK+PZmnI3JPu97TDeNt6SMiQ2FkklP79hP2yDFYSnA989OA==} engines: {node: '>=22.0.0'} @@ -4203,53 +4076,24 @@ snapshots: '@cipherstash/eql@3.0.2': {} - '@cipherstash/protect-ffi-darwin-arm64@0.23.0': - optional: true - '@cipherstash/protect-ffi-darwin-arm64@0.30.0': optional: true - '@cipherstash/protect-ffi-darwin-x64@0.23.0': - optional: true - '@cipherstash/protect-ffi-darwin-x64@0.30.0': optional: true - '@cipherstash/protect-ffi-linux-arm64-gnu@0.23.0': - optional: true - '@cipherstash/protect-ffi-linux-arm64-gnu@0.30.0': optional: true - '@cipherstash/protect-ffi-linux-x64-gnu@0.23.0': - optional: true - '@cipherstash/protect-ffi-linux-x64-gnu@0.30.0': optional: true - '@cipherstash/protect-ffi-linux-x64-musl@0.23.0': - optional: true - '@cipherstash/protect-ffi-linux-x64-musl@0.30.0': optional: true - '@cipherstash/protect-ffi-win32-x64-msvc@0.23.0': - optional: true - '@cipherstash/protect-ffi-win32-x64-msvc@0.30.0': optional: true - '@cipherstash/protect-ffi@0.23.0': - dependencies: - '@neon-rs/load': 0.1.82 - optionalDependencies: - '@cipherstash/protect-ffi-darwin-arm64': 0.23.0 - '@cipherstash/protect-ffi-darwin-x64': 0.23.0 - '@cipherstash/protect-ffi-linux-arm64-gnu': 0.23.0 - '@cipherstash/protect-ffi-linux-x64-gnu': 0.23.0 - '@cipherstash/protect-ffi-linux-x64-musl': 0.23.0 - '@cipherstash/protect-ffi-win32-x64-msvc': 0.23.0 - '@cipherstash/protect-ffi@0.30.0': dependencies: '@neon-rs/load': 0.1.82 @@ -5134,8 +4978,6 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@stricli/core@1.2.9': {} - '@supabase/auth-js@2.110.2': dependencies: tslib: 2.8.1 diff --git a/scripts/__tests__/bench-index-expressions.test.mjs b/scripts/__tests__/bench-index-expressions.test.mjs new file mode 100644 index 000000000..0ea8c9d10 --- /dev/null +++ b/scripts/__tests__/bench-index-expressions.test.mjs @@ -0,0 +1,142 @@ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '../../..') + +// The bench exists to prove the functional-index path engages. An index whose +// expression is merely "a term extractor for the right column" satisfies +// `CREATE INDEX` and satisfies the db-only smoke test's `pg_indexes` check, and +// is still never used — the planner only matches an index against the +// expression the operator INLINES TO. `eql_v3.ste_vec(enc_jsonb)` was exactly +// that: a valid, buildable, permanently-dead index, because `@>` on +// `eql_v3_json_search` inlines to `eql_v3.to_ste_vec_query(a)::jsonb`. +// +// Every EQL v3 operator wrapper is `LANGUAGE sql IMMUTABLE STRICT` with a +// single-SELECT body, which is what makes it inlinable — and Postgres runs the +// same simplification over stored index expressions, so the two meet only if +// the index names the function in the operator's BODY. +// +// This test reads the vendored EQL bundle (the same SQL `stash eql install` +// applies) and pins each bench index to the function its operator actually +// lowers to. It needs no database and no credentials, which is the point: the +// bench's own EXPLAIN assertions require CipherStash credentials and never run +// in CI. + +const require = createRequire(resolve(REPO_ROOT, 'packages/cli/package.json')) + +/** The EQL v3 bundle `stash eql install --eql-version 3` applies. */ +function bundleSql() { + const pkgJson = require.resolve('@cipherstash/eql/package.json') + return readFileSync( + resolve(dirname(pkgJson), 'dist/sql/cipherstash-encrypt.sql'), + 'utf8', + ) +} + +/** + * The body of one `CREATE FUNCTION` in the bundle, from its signature line to + * the `$$` that closes it. + */ +function functionBody(sql, signature) { + const start = sql.indexOf(signature) + expect(start, `bundle has no ${signature}`).toBeGreaterThan(-1) + const bodyStart = sql.indexOf('$$', start) + const bodyEnd = sql.indexOf('$$', bodyStart + 2) + return sql.slice(bodyStart + 2, bodyEnd) +} + +/** `eql_v3.<name>(` calls appearing in a SQL fragment. */ +function eqlCalls(fragment) { + return [...fragment.matchAll(/eql_v3\.([a-z_0-9]+)\s*\(/g)].map((m) => m[1]) +} + +const SCHEMA_SQL = readFileSync( + resolve(REPO_ROOT, 'packages/bench/sql/schema.sql'), + 'utf8', +) + +/** `CREATE INDEX <name> ... (<expression>)` pairs from the bench fixture. */ +function benchIndexes() { + const found = new Map() + for (const m of SCHEMA_SQL.matchAll( + /CREATE INDEX\s+(\w+)\s+ON bench USING \w+\s*\(([\s\S]*?)\);/g, + )) { + found.set(m[1], m[2].trim()) + } + return found +} + +/** + * Each bench index, and the operator wrapper whose inlined body it has to + * match. Signatures are the `query_<domain>` overloads — the narrowed query + * term is what the Drizzle adapter casts its operand to. + */ +const INDEX_CONTRACTS = [ + { + index: 'bench_text_hmac_idx', + operator: + 'CREATE FUNCTION eql_v3.eq(a public.eql_v3_text_search, b eql_v3.query_text_search)', + inlinesTo: 'eq_term', + }, + { + index: 'bench_text_bloom_idx', + operator: + 'CREATE FUNCTION eql_v3.matches(a public.eql_v3_text_search, b eql_v3.query_text_search)', + inlinesTo: 'match_term', + }, + { + index: 'bench_jsonb_stevec_idx', + operator: + 'CREATE FUNCTION eql_v3."@>"(a public.eql_v3_json_search, b eql_v3.query_json)', + inlinesTo: 'to_ste_vec_query', + }, +] + +describe('bench index expressions match the EQL v3 operator lowering', () => { + const sql = bundleSql() + const indexes = benchIndexes() + + it('parses all three bench indexes (guards a silently-empty regex)', () => { + expect([...indexes.keys()].sort()).toEqual( + INDEX_CONTRACTS.map((c) => c.index).sort(), + ) + }) + + it.each( + INDEX_CONTRACTS, + )('$index is built on the function $operator inlines to', ({ + index, + operator, + inlinesTo, + }) => { + const body = functionBody(sql, operator) + + // The operator really does lower to this function — if the bundle + // changes shape, this fails here rather than silently passing the + // schema check below against a stale assumption. + expect( + eqlCalls(body), + `${operator} no longer calls eql_v3.${inlinesTo}`, + ).toContain(inlinesTo) + + expect( + eqlCalls(indexes.get(index)), + `${index} must index eql_v3.${inlinesTo}(...) — the expression the ` + + 'operator inlines to — or the planner can never match it', + ).toEqual([inlinesTo]) + }) + + it('the operator wrappers are inlinable (LANGUAGE sql IMMUTABLE STRICT)', () => { + for (const { operator } of INDEX_CONTRACTS) { + const start = sql.indexOf(operator) + const header = sql.slice(start, sql.indexOf('$$', start)) + // A plpgsql or VOLATILE wrapper would never be inlined, so no functional + // index on its body could ever engage. + expect(header.toLowerCase(), operator).toMatch(/language sql/) + expect(header.toLowerCase(), operator).toMatch(/immutable/) + } + }) +}) diff --git a/scripts/__tests__/cli-vitest-alias.test.mjs b/scripts/__tests__/cli-vitest-alias.test.mjs new file mode 100644 index 000000000..5d93db5e7 --- /dev/null +++ b/scripts/__tests__/cli-vitest-alias.test.mjs @@ -0,0 +1,65 @@ +import { existsSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import cliVitestConfig from '../../packages/cli/vitest.config.ts' + +/** + * `packages/cli/vitest.config.ts` carries its own alias map, outside the one + * `vitest-shared-alias.test.mjs` guards. It has to: closing the CLI suite's + * remaining build coupling would need `stackSourceAlias`, whose `'@/'` entry + * points at `packages/stack/src` and would clobber the CLI's own `'@/'`. So the + * map is package-local and needs its own guard, for the same reason — an alias + * pointing at a moved file fails LATE, with a "failed to resolve import" naming + * a path nobody wrote. + * + * `@cipherstash/migrate` is pinned by name because it is load-bearing, not + * cosmetic: without it, 10 files / 177 tests fail to COLLECT on an unbuilt + * workspace (the transitive `src` importers, not just the one mocked test). + * `pnpm run test:scripts` runs ahead of the build-dependent suite in CI, so + * this fires before that failure would (#787 review). + */ + +const aliases = cliVitestConfig.resolve.alias + +/** + * Vite also accepts the array form (`[{ find, replacement }]`). This guard + * reads the object-of-strings form; on the array form every check below would + * throw `TypeError: target.endsWith is not a function` from a helper, which + * reads as "the guard is broken" rather than "the config changed shape". + * Fail here instead, naming the offender. + */ +const nonStringTargets = Object.entries(aliases).filter( + ([, target]) => typeof target !== 'string', +) + +/** Directory aliases are written with a trailing slash (`'@/'`). */ +const targets = Object.entries(aliases) + .filter(([, target]) => typeof target === 'string') + .map(([specifier, target]) => [ + specifier, + target.endsWith('/') ? target.slice(0, -1) : target, + ]) + +describe('packages/cli vitest alias map', () => { + it('is the object-of-strings form this guard can read', () => { + expect( + nonStringTargets.map(([specifier]) => specifier), + 'packages/cli/vitest.config.ts switched away from the object-of-strings alias form. Update this guard to walk the new shape — do not delete it.', + ).toEqual([]) + }) + + it('still aliases @cipherstash/migrate to source', () => { + // migrate publishes `./dist` only. Drop this entry — or re-introduce an + // `importOriginal()` spread that needs the built package — and the unit + // suite silently regains a dependency on a prior `turbo run build`. + expect(Object.keys(aliases)).toContain('@cipherstash/migrate') + expect(aliases['@cipherstash/migrate']).toMatch( + /packages[/\\]migrate[/\\]src[/\\]index\.ts$/, + ) + }) + + it.each( + targets, + )('%s resolves to a file that exists', (_specifier, target) => { + expect(existsSync(target), `missing alias target: ${target}`).toBe(true) + }) +}) diff --git a/scripts/__tests__/fixtures/lint-no-dead-package-paths/dead-ref.md b/scripts/__tests__/fixtures/lint-no-dead-package-paths/dead-ref.md new file mode 100644 index 000000000..4863381c2 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-dead-package-paths/dead-ref.md @@ -0,0 +1,4 @@ +# Dead reference + +- `packages/protect/src/ffi/*` mirrors this flow under the older name. +- `packages/stack` is fine. diff --git a/scripts/__tests__/fixtures/lint-no-dead-package-paths/dead-ref.yml b/scripts/__tests__/fixtures/lint-no-dead-package-paths/dead-ref.yml new file mode 100644 index 000000000..4975c8373 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-dead-package-paths/dead-ref.yml @@ -0,0 +1,3 @@ +ignore: + # Dependabot once bumped packages/protect off its pin. + - dependency-name: "@cipherstash/protect-ffi" diff --git a/scripts/__tests__/fixtures/lint-no-dead-package-paths/dead-shell.md b/scripts/__tests__/fixtures/lint-no-dead-package-paths/dead-shell.md new file mode 100644 index 000000000..9b25c6550 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-dead-package-paths/dead-shell.md @@ -0,0 +1,4 @@ +# A deleted package whose build output survives on disk + +The old thing lived in packages/lint-dead-shell-probe, deleted from git but +still sitting there as a `dist/` shell on any checkout that once built it. diff --git a/scripts/__tests__/fixtures/lint-no-dead-package-paths/dir-with-changelog/CHANGELOG.md b/scripts/__tests__/fixtures/lint-no-dead-package-paths/dir-with-changelog/CHANGELOG.md new file mode 100644 index 000000000..66eedb203 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-dead-package-paths/dir-with-changelog/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Removed `packages/protect` and `packages/schema`. diff --git a/scripts/__tests__/fixtures/lint-no-dead-package-paths/dir/nested.md b/scripts/__tests__/fixtures/lint-no-dead-package-paths/dir/nested.md new file mode 100644 index 000000000..e84569e11 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-dead-package-paths/dir/nested.md @@ -0,0 +1 @@ +See `packages/protect/src/index.ts`. diff --git a/scripts/__tests__/fixtures/lint-no-dead-package-paths/globs.md b/scripts/__tests__/fixtures/lint-no-dead-package-paths/globs.md new file mode 100644 index 000000000..809fcbf07 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-dead-package-paths/globs.md @@ -0,0 +1,2 @@ +Build everything with `turbo build --filter './packages/*'`. +The workspace glob is `packages/*`. diff --git a/scripts/__tests__/fixtures/lint-no-dead-package-paths/live-refs.md b/scripts/__tests__/fixtures/lint-no-dead-package-paths/live-refs.md new file mode 100644 index 000000000..f6ea4400f --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-dead-package-paths/live-refs.md @@ -0,0 +1,5 @@ +# Live references + +- `packages/stack/src/encryption/index.ts` is the client. +- `packages/cli` owns the `stash` binary; see `./packages/cli/AGENTS.md`. +- `packages/stack-drizzle` and `packages/stack-supabase` are the split adapters. diff --git a/scripts/__tests__/fixtures/lint-no-dead-package-paths/many-dead-refs.md b/scripts/__tests__/fixtures/lint-no-dead-package-paths/many-dead-refs.md new file mode 100644 index 000000000..f3c075aad --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-dead-package-paths/many-dead-refs.md @@ -0,0 +1,4 @@ +- packages/protect +- packages/schema +- packages/protect-dynamodb +- packages/stack diff --git a/scripts/__tests__/fixtures/lint-no-dead-package-paths/prefix.md b/scripts/__tests__/fixtures/lint-no-dead-package-paths/prefix.md new file mode 100644 index 000000000..6c9aa347d --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-dead-package-paths/prefix.md @@ -0,0 +1,3 @@ +- `packages/stack-drizzle` exists. +- `packages/stack-forge` does not. +- `packages/stack` exists. diff --git a/scripts/__tests__/fixtures/lint-no-dead-package-paths/sentence-final.md b/scripts/__tests__/fixtures/lint-no-dead-package-paths/sentence-final.md new file mode 100644 index 000000000..7ae81cfb4 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-dead-package-paths/sentence-final.md @@ -0,0 +1,7 @@ +# Sentence-final package paths + +The client lives in packages/stack. +A hyphen at a wrap: packages/migrate- +An underscore: packages/wizard_ +An ellipsis: packages/cli... +Parenthesised (packages/stack) and comma'd packages/cli, both fine. diff --git a/scripts/__tests__/fixtures/lint-no-dead-package-paths/untracked-package.md b/scripts/__tests__/fixtures/lint-no-dead-package-paths/untracked-package.md new file mode 100644 index 000000000..4f808752e --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-dead-package-paths/untracked-package.md @@ -0,0 +1,3 @@ +# A package that exists on disk but is not yet tracked + +The new thing lives in packages/lint-untracked-probe, scaffolded but not staged. diff --git a/scripts/__tests__/fixtures/lint-no-dead-package-paths/uppercase.md b/scripts/__tests__/fixtures/lint-no-dead-package-paths/uppercase.md new file mode 100644 index 000000000..a2060a9c9 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-dead-package-paths/uppercase.md @@ -0,0 +1,3 @@ +# Uppercase package names must still be checked + +See packages/Foo/does/not/exist for details. diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/excludes-dist/package.json b/scripts/__tests__/fixtures/lint-typecheck-scope/excludes-dist/package.json new file mode 100644 index 000000000..c35a0d5ac --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/excludes-dist/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fixture/excludes-dist", + "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json" } +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/excludes-dist/tsconfig.json b/scripts/__tests__/fixtures/lint-typecheck-scope/excludes-dist/tsconfig.json new file mode 100644 index 000000000..0aecccec4 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/excludes-dist/tsconfig.json @@ -0,0 +1,4 @@ +{ + "compilerOptions": { "strict": true, "noEmit": true }, + "exclude": ["dist", "node_modules"] +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/has-include/package.json b/scripts/__tests__/fixtures/lint-typecheck-scope/has-include/package.json new file mode 100644 index 000000000..46a9e7c53 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/has-include/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fixture/has-include", + "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json" } +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/has-include/tsconfig.json b/scripts/__tests__/fixtures/lint-typecheck-scope/has-include/tsconfig.json new file mode 100644 index 000000000..e0ba56aa8 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/has-include/tsconfig.json @@ -0,0 +1,4 @@ +{ + "compilerOptions": { "strict": true, "noEmit": true }, + "include": ["src/**/*.ts"] +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/jsonc-paths/package.json b/scripts/__tests__/fixtures/lint-typecheck-scope/jsonc-paths/package.json new file mode 100644 index 000000000..f6c511632 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/jsonc-paths/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fixture/jsonc-paths", + "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json" } +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/jsonc-paths/tsconfig.json b/scripts/__tests__/fixtures/lint-typecheck-scope/jsonc-paths/tsconfig.json new file mode 100644 index 000000000..f384a6d17 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/jsonc-paths/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + /* A block comment, and below a path mapping whose KEY contains `/*` — a + naive strip eats from inside that string to the next comment close. */ + "paths": { + "@/*": ["./src/*"] + }, + "strict": true + }, + // A trailing comma follows, which `JSON.parse` also rejects. + "exclude": ["dist", "node_modules"] +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/no-gate/package.json b/scripts/__tests__/fixtures/lint-typecheck-scope/no-gate/package.json new file mode 100644 index 000000000..24edb2530 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/no-gate/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fixture/no-gate", + "scripts": { "build": "tsup" } +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/no-gate/tsconfig.json b/scripts/__tests__/fixtures/lint-typecheck-scope/no-gate/tsconfig.json new file mode 100644 index 000000000..98865a3ea --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/no-gate/tsconfig.json @@ -0,0 +1,3 @@ +{ + "compilerOptions": { "strict": true, "noEmit": true } +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped-too/package.json b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped-too/package.json new file mode 100644 index 000000000..f1126d4b3 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped-too/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fixture/unscoped-too", + "scripts": { "typecheck": "tsc --project tsconfig.json --noEmit" } +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped-too/tsconfig.json b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped-too/tsconfig.json new file mode 100644 index 000000000..636f8a894 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped-too/tsconfig.json @@ -0,0 +1,4 @@ +{ + "compilerOptions": { "strict": true, "noEmit": true }, + "exclude": ["node_modules"] +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped/package.json b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped/package.json new file mode 100644 index 000000000..abd692cf5 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fixture/unscoped", + "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json" } +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped/tsconfig.json b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped/tsconfig.json new file mode 100644 index 000000000..98865a3ea --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped/tsconfig.json @@ -0,0 +1,3 @@ +{ + "compilerOptions": { "strict": true, "noEmit": true } +} diff --git a/scripts/__tests__/lib/read-jsonc.mjs b/scripts/__tests__/lib/read-jsonc.mjs new file mode 100644 index 000000000..3ecff8b80 --- /dev/null +++ b/scripts/__tests__/lib/read-jsonc.mjs @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs' + +/** + * Parse a JSONC file (`turbo.json` and friends allow comments and trailing + * commas; `JSON.parse` does not). + * + * String-aware by necessity, not fussiness: `turbo.json` opens with + * `"$schema": "https://turbo.build/schema.json"`, so a regex that strips `//` + * without tracking string state eats the URL and yields invalid JSON — or, + * worse, silently valid JSON with the wrong contents. + * + * Extracted from `turbo-skills-inputs.test.mjs` when a second guard needed it. + * Two hand-rolled copies of this would drift, and a subtly wrong copy fails as + * a confusing `SyntaxError` at a byte offset rather than as a clear defect. + */ +export function readJsonc(path) { + const raw = readFileSync(path, 'utf8') + let out = '' + let inString = false + let escaped = false + for (let i = 0; i < raw.length; i++) { + const ch = raw[i] + if (inString) { + out += ch + if (escaped) escaped = false + else if (ch === '\\') escaped = true + else if (ch === '"') inString = false + continue + } + if (ch === '"') { + inString = true + out += ch + continue + } + if (ch === '/' && raw[i + 1] === '/') { + while (i < raw.length && raw[i] !== '\n') i++ + out += '\n' + continue + } + if (ch === '/' && raw[i + 1] === '*') { + i += 2 + while (i < raw.length && !(raw[i] === '*' && raw[i + 1] === '/')) i++ + i++ + continue + } + out += ch + } + return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1')) +} diff --git a/scripts/__tests__/lint-no-dead-package-paths.test.mjs b/scripts/__tests__/lint-no-dead-package-paths.test.mjs new file mode 100644 index 000000000..92f9d7aff --- /dev/null +++ b/scripts/__tests__/lint-no-dead-package-paths.test.mjs @@ -0,0 +1,252 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const SCRIPT = resolve( + fileURLToPath(import.meta.url), + '../../lint-no-dead-package-paths.mjs', +) + +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '../../..') + +function run(...targets) { + return runWith({}, ...targets) +} + +function runWith(opts, ...targets) { + try { + execFileSync(process.execPath, [SCRIPT, ...targets], { + encoding: 'utf8', + ...opts, + }) + return { exitCode: 0, output: '' } + } catch (err) { + return { + exitCode: err.status, + output: String(err.stdout) + String(err.stderr), + } + } +} + +const fx = (name) => + resolve( + fileURLToPath(import.meta.url), + `../fixtures/lint-no-dead-package-paths/${name}`, + ) + +describe('lint-no-dead-package-paths', () => { + it('passes on the repo as it stands', () => { + const r = run() + expect(r.output).toBe('') + expect(r.exitCode).toBe(0) + }) + + it('passes when every `packages/<name>` reference resolves', () => { + expect(run(fx('live-refs.md')).exitCode).toBe(0) + }) + + it('fails on a reference to a deleted package', () => { + const r = run(fx('dead-ref.md')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/packages\/protect/) + }) + + // #772 review, finding 15. The name capture had no right anchor, so a + // sentence-final `packages/stack.` swallowed the period and the linter + // reported a LIVE package as dead — failing the build with a message naming a + // directory that plainly exists. Never fired in 400 commits only because the + // repo's backtick convention happened to dodge it. + it('does not flag a live package followed by sentence punctuation', () => { + const r = run(fx('sentence-final.md')) + expect(r.output).toBe('') + expect(r.exitCode).toBe(0) + }) + + // The character class excluded uppercase, so `packages/Foo` was never checked + // at all — a silent hole rather than a false alarm. + it('checks a package name containing uppercase', () => { + const r = run(fx('uppercase.md')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/packages\/Foo/) + }) + + // The linters carry package paths of their own; `scripts/` was not scanned, + // so lint-no-hardcoded-runners' `packages/drizzle/src/bin/runner.ts` allowlist + // entry — added speculatively by c6715608, load-bearing 31 minutes later once + // 9d259e6e created the file — sat dead for the two months after 413ca396 + // deleted the package. Its sibling entry for `packages/protect` was removed + // with its package; this one was simply missed. + // + // Asserting the repo is clean would NOT pin this: the suite's first test + // already does that, and both pass whether or not `scripts` is in TARGETS. + // Plant an offender in the scanned directory instead. + it('scans scripts/ but not its fixtures', () => { + const probe = resolve(REPO_ROOT, 'scripts/dead-path-probe.md') + try { + writeFileSync(probe, 'A reference to `packages/protect`, long gone.\n') + const r = run() + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/scripts\/dead-path-probe\.md:1/) + // `__tests__` stays skipped — the fixtures name dead packages on purpose, + // and so do the comments in this very file. + expect(r.output).not.toMatch(/__tests__/) + } finally { + rmSync(probe, { force: true }) + } + }) + + // A package scaffolded a minute ago is live, but nothing about it is tracked + // yet. Deriving the live set from `git ls-files` alone reported it as "does + // not exist" — the same species of false alarm as the sentence-final one + // above, pointed the other way, at a directory sitting right there on disk. + it('treats a package that exists but is not yet tracked as live', () => { + const pkg = resolve(REPO_ROOT, 'packages/lint-untracked-probe') + try { + mkdirSync(resolve(pkg, 'src'), { recursive: true }) + writeFileSync(resolve(pkg, 'src/index.ts'), 'export const x = 1\n') + const r = run(fx('untracked-package.md')) + expect(r.output).toBe('') + expect(r.exitCode).toBe(0) + } finally { + rmSync(pkg, { recursive: true, force: true }) + } + }) + + // The whole reason `livePackages` comes from git rather than from + // `readdirSync`, pinned in the discriminating direction (#772 review, + // finding 15). + // + // Deleting a package leaves its gitignored `dist/` and `node_modules/` + // behind, so the directory is still on disk and any filesystem-derived live + // set calls it live — silently excusing every reference to it. The test + // above, `treats a package that exists but is not yet tracked as live`, does + // NOT pin this: it builds a divergence that `readdirSync` and git agree on, + // so it passes under a revert. This one is the only test that fails under + // both a plain `readdirSync` revert and the more plausible + // `readdirSync`-unioned-with-git hybrid. + // + // The shell's contents MUST be gitignored. If they aren't, the + // `--others --exclude-standard` arm legitimately counts the package as live + // and this test would assert the opposite of what it claims — so guard on a + // clean `git status` and fail loudly rather than silently degrading. + it('flags a deleted package whose gitignored dist/ shell survives on disk', () => { + const shell = resolve(REPO_ROOT, 'packages/lint-dead-shell-probe') + try { + mkdirSync(resolve(shell, 'dist'), { recursive: true }) + writeFileSync(resolve(shell, 'dist/index.js'), 'exports.x = 1\n') + mkdirSync(resolve(shell, 'node_modules'), { recursive: true }) + writeFileSync(resolve(shell, 'node_modules/.keep'), '') + + // Scoped to the probe alone: asserting the whole `packages/` tree is + // clean would couple this to whatever else is uncommitted in the + // working copy, which is nobody's business but the developer's. + const visible = execFileSync( + 'git', + ['status', '--porcelain', 'packages/lint-dead-shell-probe'], + { cwd: REPO_ROOT, encoding: 'utf8' }, + ) + expect(visible).toBe('') + + const r = run(fx('dead-shell.md')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/packages\/lint-dead-shell-probe/) + } finally { + rmSync(shell, { recursive: true, force: true }) + } + }) + + // The live set is derived by shelling out to git, so git failing is a mode + // this linter has to own. Exiting 1 with a raw ENOENT stack trace would be + // indistinguishable from a genuine lint failure; exit 2 says "the linter + // could not run", not "your docs are wrong". + it('exits 2 with an actionable message when git is unavailable', () => { + const r = runWith({ + env: { PATH: resolve(REPO_ROOT, 'scripts/nonexistent') }, + }) + expect(r.exitCode).toBe(2) + expect(r.output).toMatch(/git/) + expect(r.output).toMatch(/checkout/) + expect(r.output).not.toMatch(/at ModuleJob/) + }) + + // A linter whose whole job is catching dead paths in configuration silently + // ignored dead paths in its OWN configuration: a target that did not exist + // was skipped, so a renamed or moved entry dropped out of coverage forever + // with a green build. Its sibling `lint-no-hardcoded-runners.mjs` exits 2 on + // a stale allowlist entry — same rot, opposite handling, same PR. + // + // Exit 2, not 1: the linter could not check what it was asked to check, + // which is not the same as "your docs are wrong". + it('exits 2 when a target does not exist rather than skipping it', () => { + const r = run(fx('no-such-fixture.md')) + expect(r.exitCode).toBe(2) + expect(r.output).toMatch(/no-such-fixture\.md/) + expect(r.output).not.toMatch(/at ModuleJob/) + }) + + // `relative(REPO_ROOT, file)` renders a target outside the repo as a + // `../../../../..` chain climbing out of the root — technically resolvable, + // unreadable in practice, and it buries the one thing the line is for. + // Unreachable via the default target list, which is repo-relative + // throughout; reachable the moment anyone passes an argv override. + it('renders an absolute path for an offender outside the repo root', () => { + const dir = mkdtempSync(join(tmpdir(), 'lint-dead-package-paths-')) + try { + const file = join(dir, 'outside.md') + writeFileSync(file, 'Points at packages/lint-dead-shell-probe, gone.\n') + const r = run(file) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/packages\/lint-dead-shell-probe/) + expect(r.output).toContain(file) + expect(r.output).not.toMatch(/\.\.\//) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('names the file and line of each offender', () => { + const r = run(fx('dead-ref.md')) + expect(r.output).toMatch(/dead-ref\.md:3/) + }) + + it('reports every offender, not just the first', () => { + const r = run(fx('many-dead-refs.md')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/packages\/protect\b/) + expect(r.output).toMatch(/packages\/schema\b/) + expect(r.output).toMatch(/packages\/protect-dynamodb\b/) + }) + + it('flags dead paths in YAML comments too', () => { + const r = run(fx('dead-ref.yml')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/packages\/protect/) + }) + + it('matches the longest package name, not a shorter prefix', () => { + // `packages/stack-drizzle` must not be read as `packages/stack` + suffix, + // and a dead `packages/stack-forge` must not be excused by live + // `packages/stack`. + const r = run(fx('prefix.md')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/packages\/stack-forge/) + expect(r.output).not.toMatch(/packages\/stack-drizzle/) + }) + + it('ignores `packages/*` globs and `./packages/*` filters', () => { + expect(run(fx('globs.md')).exitCode).toBe(0) + }) + + it('walks a directory target', () => { + const r = run(fx('dir')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/nested\.md/) + }) + + it('skips CHANGELOG.md — released history names deleted packages', () => { + expect(run(fx('dir-with-changelog')).exitCode).toBe(0) + }) +}) diff --git a/scripts/__tests__/lint-no-hardcoded-runners.test.mjs b/scripts/__tests__/lint-no-hardcoded-runners.test.mjs index 7bb1202dc..709ac63ed 100644 --- a/scripts/__tests__/lint-no-hardcoded-runners.test.mjs +++ b/scripts/__tests__/lint-no-hardcoded-runners.test.mjs @@ -1,5 +1,7 @@ import { execFileSync } from 'node:child_process' -import { resolve } from 'node:path' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' @@ -8,9 +10,9 @@ const SCRIPT = resolve( '../../lint-no-hardcoded-runners.mjs', ) -function run(target) { +function runScript(script, ...targets) { try { - execFileSync('node', [SCRIPT, target], { encoding: 'utf8' }) + execFileSync(process.execPath, [script, ...targets], { encoding: 'utf8' }) return { exitCode: 0, output: '' } } catch (err) { return { @@ -20,6 +22,10 @@ function run(target) { } } +function run(target) { + return runScript(SCRIPT, target) +} + describe('lint-no-hardcoded-runners', () => { const fx = (name) => resolve(fileURLToPath(import.meta.url), `../fixtures/${name}`) @@ -35,6 +41,32 @@ describe('lint-no-hardcoded-runners', () => { expect(r.output).toMatch(/\bnpx\b/) }) + // `statSync` on a missing target threw uncaught: exit 1 plus a raw stack + // trace, indistinguishable from "found a hardcoded npx" to anything reading + // the exit code. Exit 2 means the linter could not run. + it('exits 2 when a target does not exist', () => { + const r = run(fx('no-such-fixture.ts')) + expect(r.exitCode).toBe(2) + expect(r.output).toContain('no-such-fixture.ts') + expect(r.output).not.toMatch(/at ModuleJob/) + }) + + // Matches the sibling linter: a target outside the repo rendered as a + // `../../../../..` chain out of the root instead of naming the file. + it('renders an absolute path for an offender outside the repo root', () => { + const dir = mkdtempSync(join(tmpdir(), 'lint-hardcoded-runners-')) + try { + const file = join(dir, 'outside.ts') + writeFileSync(file, "export const cmd = 'npx drizzle-kit generate'\n") + const r = run(file) + expect(r.exitCode).toBe(1) + expect(r.output).toContain(file) + expect(r.output).not.toMatch(/\.\.\//) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + it("ignores `?? 'npx'` fallback expressions", () => { expect(run(fx('allowed-fallback.ts')).exitCode).toBe(0) }) @@ -70,3 +102,51 @@ describe('lint-no-hardcoded-runners', () => { expect(run(fx('identifier.ts')).exitCode).toBe(0) }) }) + +// An allowlist entry is a standing exemption. When the file it names is deleted +// — or stops carrying the `npx` literal it was excused for — the entry becomes +// silent dead weight, and the next reader takes it as evidence that the file +// still needs an exemption. `packages/drizzle/src/bin/runner.ts` sat here for +// the two months after 413ca396 deleted its package, and surfaced only because +// a sibling linter happened to start scanning `scripts/` (#772 review, finding +// 15). That sibling can only ever catch the `packages/<name>` shape; this check +// covers every entry, including a stale path inside a live package. +describe('lint-no-hardcoded-runners — allowlist hygiene', () => { + // A copy alongside the original so `REPO_ROOT` still resolves to the repo. + const PROBE = resolve( + fileURLToPath(import.meta.url), + '../../allowlist-probe.mjs', + ) + + function runWithExtraEntry(entry) { + const src = readFileSync(SCRIPT, 'utf8').replace( + 'const ALLOWLISTED_PATHS = new Set([', + `const ALLOWLISTED_PATHS = new Set([\n '${entry}',`, + ) + try { + writeFileSync(PROBE, src) + return runScript(PROBE) + } finally { + rmSync(PROBE, { force: true }) + } + } + + it('rejects an entry whose file no longer exists', () => { + const r = runWithExtraEntry('scripts/deleted-helper.mjs') + expect(r.exitCode).toBe(2) + expect(r.output).toMatch(/scripts\/deleted-helper\.mjs/) + expect(r.output).toMatch(/no such file/) + }) + + it('rejects an entry whose file no longer needs the exemption', () => { + const r = runWithExtraEntry('scripts/vitest.config.mjs') + expect(r.exitCode).toBe(2) + expect(r.output).toMatch(/scripts\/vitest\.config\.mjs/) + expect(r.output).toMatch(/no longer contains/) + }) + + it('accepts the allowlist as it stands', () => { + const r = runScript(SCRIPT) + expect(r.exitCode).toBe(0) + }) +}) diff --git a/scripts/__tests__/lint-typecheck-scope.test.mjs b/scripts/__tests__/lint-typecheck-scope.test.mjs new file mode 100644 index 000000000..093f9cf0a --- /dev/null +++ b/scripts/__tests__/lint-typecheck-scope.test.mjs @@ -0,0 +1,87 @@ +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const SCRIPT = resolve( + fileURLToPath(import.meta.url), + '../../lint-typecheck-scope.mjs', +) + +function run(...targets) { + try { + execFileSync('node', [SCRIPT, ...targets], { encoding: 'utf8' }) + return { exitCode: 0, output: '' } + } catch (err) { + return { + exitCode: err.status, + output: String(err.stdout) + String(err.stderr), + } + } +} + +const fx = (name) => `scripts/__tests__/fixtures/lint-typecheck-scope/${name}` + +describe('lint-typecheck-scope', () => { + it('passes on the repo as it stands', () => { + const r = run() + expect(r.output).toBe('') + expect(r.exitCode).toBe(0) + }) + + it('fails a gated package whose tsconfig scopes nothing', () => { + const r = run(fx('unscoped')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/@fixture\/unscoped/) + expect(r.output).toMatch(/compiles its own build output/) + }) + + it('names the offending tsconfig and the gate command', () => { + const r = run(fx('unscoped')) + expect(r.output).toMatch(/unscoped\/tsconfig\.json/) + expect(r.output).toMatch(/tsc --noEmit -p tsconfig\.json/) + }) + + it('passes when `exclude` covers dist', () => { + expect(run(fx('excludes-dist')).exitCode).toBe(0) + }) + + it('passes when an explicit `include` scopes the program', () => { + expect(run(fx('has-include')).exitCode).toBe(0) + }) + + it('ignores a package with no typecheck gate', () => { + // An unscoped tsconfig nothing runs is an editor setting, not a CI + // contract — flagging it would be noise. + expect(run(fx('no-gate')).exitCode).toBe(0) + }) + + it('parses a tsconfig with comments, block comments and a `/*` inside a path key', () => { + // Regression: the first cut stripped block comments with a regex, which ate + // from the `/*` inside the `"@/*"` mapping key to the next comment close and + // reported four real tsconfigs as unparseable. + const r = run(fx('jsonc-paths')) + expect(r.output).not.toMatch(/could not be parsed/) + expect(r.exitCode).toBe(0) + }) + + it('counts only the offenders among the targets it was given', () => { + const r = run(fx('unscoped'), fx('no-gate'), fx('excludes-dist')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/Found 1 typecheck gate\(s\)/) + }) + + it('reports every offender, not just the first', () => { + const r = run(fx('unscoped'), fx('unscoped-too')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/Found 2 typecheck gate\(s\)/) + expect(r.output).toMatch(/@fixture\/unscoped\b/) + expect(r.output).toMatch(/@fixture\/unscoped-too/) + }) + + it('explains the fix, including that `exclude` replaces the default', () => { + const r = run(fx('unscoped')) + expect(r.output).toMatch(/"exclude": \["dist", "node_modules"\]/) + expect(r.output).toMatch(/REPLACES the default/) + }) +}) diff --git a/scripts/__tests__/no-removed-drizzle-surface.test.mjs b/scripts/__tests__/no-removed-drizzle-surface.test.mjs new file mode 100644 index 000000000..64334bce4 --- /dev/null +++ b/scripts/__tests__/no-removed-drizzle-surface.test.mjs @@ -0,0 +1,69 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '../../..') + +/** + * The `@cipherstash/stack-drizzle` names removed when EQL v2 went away and + * `./v3` collapsed into the package root. Nothing type-checks a README, a + * SKILL.md, or a template literal, so these three drifted silently before — + * and `skills/` ships inside the `stash` tarball, which means the drift lands + * in a customer's repo rather than in CI. + */ +const REMOVED = [ + '@cipherstash/stack-drizzle/v3', + 'extractEncryptionSchemaV3', + 'createEncryptionOperatorsV3', +] + +/** + * Files whose contents are SHIPPED — published to npm, copied into a user's + * repo, or written there by `stash init`. Deliberately not the whole tree: + * CHANGELOGs and `docs/superpowers/specs/**` are historical records that + * SHOULD still name the old surface, and rewriting history to appease a lint + * is worse than the drift it prevents. + */ +// `:(glob)` magic so `*` stops at a path separator — without it git's default +// wildmatch crosses `/` and `lib/*.ts` sweeps in `lib/__tests__/*.test.ts`. +const SHIPPED_GLOBS = [ + ':(glob)skills/*/SKILL.md', + ':(glob)packages/*/README.md', + 'README.md', + 'AGENTS.md', + // `stash init` writes these strings into the user's project as real source. + 'packages/cli/src/commands/init/utils.ts', + ':(glob)packages/cli/src/commands/init/lib/*.ts', + ':(glob)packages/cli/src/commands/init/doctrine/*.md', +] + +/** Tracked files matching the shipped globs, via git so it honours .gitignore. */ +function shippedFiles() { + const out = execFileSync('git', ['ls-files', '-z', ...SHIPPED_GLOBS], { + cwd: REPO_ROOT, + encoding: 'utf8', + }) + return out.split('\0').filter(Boolean) +} + +describe('removed stack-drizzle surface is absent from shipped files', () => { + const files = shippedFiles() + + it('finds the shipped file set (guards against a silently-empty glob)', () => { + expect(files.length).toBeGreaterThan(5) + expect(files).toContain('skills/stash-drizzle/SKILL.md') + expect(files).toContain('packages/stack/README.md') + }) + + it.each(files)('%s', (file) => { + const body = readFileSync(resolve(REPO_ROOT, file), 'utf8') + const found = REMOVED.filter((name) => body.includes(name)) + expect( + found, + `${file} names removed @cipherstash/stack-drizzle exports: ${found.join(', ')}. ` + + 'The `./v3` subpath collapsed into the package root and the *V3 suffixes were dropped.', + ).toEqual([]) + }) +}) diff --git a/scripts/__tests__/rewriter-copies-in-sync.test.mjs b/scripts/__tests__/rewriter-copies-in-sync.test.mjs new file mode 100644 index 000000000..213b1ffc5 --- /dev/null +++ b/scripts/__tests__/rewriter-copies-in-sync.test.mjs @@ -0,0 +1,111 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '../../..') + +/** + * The destructive ALTER-COLUMN rewriter exists twice: `@cipherstash/wizard` + * runs it after its agent edits a schema, and `stash eql migration --drizzle` + * runs it over an explicit `--out`. Both are published, neither depends on the + * other, and `packages/utils` is not a package while `@cipherstash/test-kit` is + * private and build-less — so there is nowhere to put shared runtime code that + * both npm tarballs could resolve. Extracting one means either publishing a new + * package into a fixed release group or adding `noExternal` to two CLI bundles. + * + * Until that happens the two files are near-clones, and drift between them is + * the failure mode: four separate #772 review findings each needed the same fix + * applied in both places, and a fix landing in only one still passes that + * package's suite. Nothing else in CI compares them. + * + * So: everything outside the wizard's `#region wizard-only` must match the CLI + * copy exactly, modulo comments and the tool name in the emitted header. + */ +const WIZARD = 'packages/wizard/src/lib/rewrite-migrations.ts' +const CLI = 'packages/cli/src/commands/db/rewrite-migrations.ts' + +const REGION_OPEN = '// #region wizard-only' +const REGION_CLOSE = '// #endregion wizard-only' + +/** Drop the wizard-only region, delimited by the markers in the source. */ +function stripWizardOnly(source) { + const open = source.indexOf(REGION_OPEN) + if (open === -1) return source + const close = source.indexOf(REGION_CLOSE, open) + if (close === -1) return source + return source.slice(0, open) + source.slice(close + REGION_CLOSE.length) +} + +/** + * Comments, imports and blank lines removed; the emitted header's tool name + * canonicalised. Both files are Biome-formatted identically, so a pure comment + * line is reliably one starting with `//`, `/*` or `*` — no line of code in + * either file starts that way (the regex literals begin `/[`). + */ +function comparableCode(source) { + return source + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .filter( + (line) => + !line.startsWith('//') && + !line.startsWith('*') && + !line.startsWith('/*'), + ) + .filter((line) => !line.startsWith('import ')) + .map((line) => + line.replace(/'-- Rewritten by [^:]+:/, "'-- Rewritten by <TOOL>:"), + ) +} + +const read = (file) => readFileSync(resolve(REPO_ROOT, file), 'utf8') + +describe('the wizard and cli rewriter copies stay in sync', () => { + const wizardSource = read(WIZARD) + const cliSource = read(CLI) + + it('marks the wizard-only region so the comparison knows what to exclude', () => { + expect(wizardSource).toContain(REGION_OPEN) + expect(wizardSource).toContain(REGION_CLOSE) + // The CLI copy has no counterpart, so it must not carry the markers. + expect(cliSource).not.toContain(REGION_OPEN) + }) + + it('finds real code in both (guards against a normaliser that strips everything)', () => { + expect( + comparableCode(stripWizardOnly(wizardSource)).length, + ).toBeGreaterThan(200) + expect(comparableCode(cliSource).length).toBeGreaterThan(200) + }) + + it('has identical shared logic', () => { + const wizard = comparableCode(stripWizardOnly(wizardSource)) + const cli = comparableCode(cliSource) + expect( + wizard.join('\n'), + `${WIZARD} and ${CLI} have drifted. Every fix to this rewriter must land ` + + "in BOTH copies — a one-sided fix still passes that package's own suite, " + + 'and this rewriter emits DROP COLUMN. If the difference is intentional and ' + + `wizard-only, move it inside the ${REGION_OPEN} region.`, + ).toBe(cli.join('\n')) + }) + + it('keeps sweepMigrationDirs as the only wizard-only export', () => { + // A second wizard-only export would mean shared logic had started to + // diverge under cover of the region marker. + const region = wizardSource.slice( + wizardSource.indexOf(REGION_OPEN), + wizardSource.indexOf(REGION_CLOSE), + ) + const exported = [ + ...region.matchAll( + /^export (?:async function|interface|const|type) (\w+)/gm, + ), + ] + .map((match) => match[1]) + .sort() + expect(exported).toEqual(['DirRewriteResult', 'sweepMigrationDirs']) + }) +}) diff --git a/scripts/__tests__/turbo-skills-inputs.test.mjs b/scripts/__tests__/turbo-skills-inputs.test.mjs new file mode 100644 index 000000000..a2413a121 --- /dev/null +++ b/scripts/__tests__/turbo-skills-inputs.test.mjs @@ -0,0 +1,84 @@ +/** + * A build that copies `skills/` must declare `skills/` as an input. + * + * `packages/cli` and `packages/wizard` both `cpSync('../../skills', + * 'dist/skills')` — they consume a directory outside their own package, which + * turbo's `$TURBO_DEFAULT$` does not cover. On its own that only meant a stale + * cache entry stayed valid; once `build` declared `outputs: ["dist/**"]`, a + * cache hit began actively RESTORING the previous `dist/skills` over the tree. + * + * `skills/` ships inside the `stash` and `@cipherstash/wizard` tarballs and + * `stash init` copies it into customer repos, so the failure mode is publishing + * guidance that was edited but never rebuilt — invisible, because the build is + * green and the source file on disk is correct. + * + * Verified by hand at the time of writing: edit a `SKILL.md`, run + * `turbo run build --filter=stash`, observe FULL TURBO and a `dist/skills` copy + * without the edit. This pins the fix so it cannot silently come undone. + */ + +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { readJsonc } from './lib/read-jsonc.mjs' + +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '../../..') + +/** Packages whose tsup config copies the repo-root `skills/` into `dist/`. */ +function packagesCopyingSkills() { + const pkgsDir = join(REPO_ROOT, 'packages') + const found = [] + for (const entry of readdirSync(pkgsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const tsup = join(pkgsDir, entry.name, 'tsup.config.ts') + const pkgJson = join(pkgsDir, entry.name, 'package.json') + if (!existsSync(tsup) || !existsSync(pkgJson)) continue + if ( + !/cpSync\(\s*['"]\.\.\/\.\.\/skills['"]/.test(readFileSync(tsup, 'utf8')) + ) + continue + found.push(JSON.parse(readFileSync(pkgJson, 'utf8')).name) + } + return found +} + +describe('turbo build inputs cover the skills directory', () => { + const turbo = readJsonc(join(REPO_ROOT, 'turbo.json')) + const copiers = packagesCopyingSkills() + + it('finds the packages that copy skills into their bundle', () => { + // If this drops to zero the rest of the suite silently passes, so pin it. + expect(copiers).toEqual( + expect.arrayContaining(['stash', '@cipherstash/wizard']), + ) + }) + + it.each( + packagesCopyingSkills().map((name) => [name]), + )('%s#build declares skills as an input', (name) => { + const task = turbo.tasks[`${name}#build`] + + expect( + task, + `turbo.json has no "${name}#build" task, so it inherits the generic ` + + '`build` inputs, which do not include the repo-root skills/ directory', + ).toBeDefined() + + const inputs = task.inputs ?? [] + expect( + inputs.some((i) => i.includes('skills')), + `"${name}#build".inputs must name the repo-root skills/ directory ` + + '(e.g. "$TURBO_ROOT$/skills/**") or a skills-only edit will not ' + + 'invalidate the build, and `outputs: ["dist/**"]` will restore a stale copy', + ).toBe(true) + }) + + it('keeps the generic build outputs on the overrides', () => { + // An override replaces the generic task wholesale — dropping `outputs` + // would stop the cache restoring dist at all for these two packages. + for (const name of copiers) { + expect(turbo.tasks[`${name}#build`].outputs).toEqual(['dist/**']) + } + }) +}) diff --git a/scripts/__tests__/vitest-shared-alias.test.mjs b/scripts/__tests__/vitest-shared-alias.test.mjs new file mode 100644 index 000000000..771d68f8a --- /dev/null +++ b/scripts/__tests__/vitest-shared-alias.test.mjs @@ -0,0 +1,34 @@ +import { existsSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { sharedAlias, stackSourceAlias } from '../../vitest.shared.ts' + +// A vite alias pointing at a file that no longer exists fails LATE and badly: +// nothing validates the map, so the entry survives a package refactor and the +// next test to use that specifier gets a "failed to resolve import" on a path +// the author never wrote, instead of the honest "subpath is not exported". +// `@cipherstash/stack-drizzle/v3` sat here for exactly that reason after the +// `./v3` collapse. + +/** Directory aliases are written with a trailing slash (`'@/'`). */ +const targets = (map) => + Object.entries(map).map(([specifier, target]) => [ + specifier, + target.endsWith('/') ? target.slice(0, -1) : target, + ]) + +describe('vitest.shared.ts alias targets exist on disk', () => { + it('exports both maps, non-empty (guards a silently-renamed export)', () => { + expect(Object.keys(sharedAlias).length).toBeGreaterThan(5) + expect(Object.keys(stackSourceAlias).length).toBeGreaterThan(0) + }) + + it.each(targets(sharedAlias))('sharedAlias %s', (_specifier, target) => { + expect(existsSync(target), `missing alias target: ${target}`).toBe(true) + }) + + it.each( + targets(stackSourceAlias), + )('stackSourceAlias %s', (_specifier, target) => { + expect(existsSync(target), `missing alias target: ${target}`).toBe(true) + }) +}) diff --git a/scripts/__tests__/workflow-turbo-build-deps.test.mjs b/scripts/__tests__/workflow-turbo-build-deps.test.mjs new file mode 100644 index 000000000..82386574d --- /dev/null +++ b/scripts/__tests__/workflow-turbo-build-deps.test.mjs @@ -0,0 +1,209 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import yaml from 'js-yaml' +import { describe, expect, it } from 'vitest' +import { readJsonc } from './lib/read-jsonc.mjs' + +/** + * A turbo task declaring `dependsOn: ["^build"]` gets its workspace + * dependencies built before it runs — but ONLY when turbo is the one invoking + * it. Run the same task as a bare package script (`pnpm --filter <pkg> run + * <task>`) and the dependency graph is skipped silently. + * + * That failure mode is invisible in CI, because a bare step usually still + * passes: some earlier step in the same job built the workspace via its own + * `^build`. The step is then load-bearing on an ordering nobody declared. + * Reorder or delete that earlier step — and they read as independent guards + * for other packages, so they look freely removable — and the bare step fails + * with a module-resolution error (`TS2307`, `Failed to resolve entry for + * package`) that reads as "the code is broken" rather than "you skipped the + * build". + * + * This is not hypothetical: #787 added `typecheck:scaffold` as a bare + * `pnpm --filter stash run typecheck:scaffold` and it went green for exactly + * that reason. The fixtures it typechecks import `@cipherstash/stack/v3`, so + * the step needs that package built; it was routed through turbo in review. + * This test pins that routing so it cannot be quietly "simplified" back. + */ + +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '../../..') + +/** + * The workflow that must carry the `typecheck:scaffold` step specifically. + * Scoped, because "that step exists" is a claim about this file, not about + * workflows in general. + */ +const SCAFFOLD_WORKFLOW = '.github/workflows/tests.yml' + +/** + * Every workflow, not just `tests.yml`. A bare build-dependent step is the same + * latent trap wherever it runs, and the integration workflows are where it is + * least visible: they build only `stash` (via the shared `integration-setup` + * action) and reach every other package through that one task's `^build`. + * Narrowing the guard to one file left five real bare invocations unchecked + * (#787 review follow-up). + */ +const WORKFLOWS = readdirSync(resolve(REPO_ROOT, '.github/workflows')) + .filter((file) => /\.ya?ml$/.test(file)) + .map((file) => `.github/workflows/${file}`) + .sort() + +/** + * Bare invocations that predate this guard. Each is the same latent trap: it + * passes today only because an earlier step in its job builds the workspace. + * They are recorded rather than ignored so the list can be worked down — do + * not add to it. Route new steps through `turbo run` instead. + */ +const KNOWN_BARE = new Set([ + 'pnpm --filter @cipherstash/prisma-next run typecheck', + 'pnpm --filter @cipherstash/wizard run typecheck', +]) + +const turboJson = readJsonc(resolve(REPO_ROOT, 'turbo.json')) +const rootScripts = + JSON.parse(readFileSync(resolve(REPO_ROOT, 'package.json'), 'utf8')) + .scripts ?? {} + +/** Turbo tasks that build their workspace dependencies first. */ +const buildDependentTasks = new Set( + Object.entries(turboJson.tasks ?? {}) + .filter(([, task]) => (task?.dependsOn ?? []).includes('^build')) + .map(([name]) => name), +) + +/** pnpm's own verbs — never package scripts. */ +const PNPM_VERBS = new Set([ + 'install', + 'exec', + 'dlx', + 'add', + 'why', + 'store', + 'run', +]) + +/** + * The task a command line runs, and whether turbo is the thing running it. + * Returns null when the line runs no recognisable task. + * + * Two shapes matter: + * `pnpm exec turbo run <task> --filter <pkg>` / `pnpm turbo <task>` → routed + * `pnpm [--filter <pkg>] [run] <task>` → bare + */ +function invokedTask(line) { + const turbo = line.match(/\bturbo\b\s+(?:run\s+)?([\w:.-]+)/) + if (turbo) return { task: turbo[1], routed: true, filtered: false } + + const pnpm = line.match( + /\bpnpm\b(?:\s+(?:--filter|-F)\s+\S+|\s+--if-present)*\s+(?:run\s+)?([\w:.-]+)/, + ) + if (!pnpm) return null + const task = pnpm[1] + if (PNPM_VERBS.has(task)) return null + return { task, routed: false, filtered: /\s(?:--filter|-F)\s/.test(line) } +} + +/** + * Root scripts may delegate to turbo themselves (`"test": "turbo test ..."`). + * + * Only ever consult this for an UNFILTERED invocation. `pnpm --filter <pkg> + * <task>` runs that package's script, so the root script's delegation says + * nothing about it — reading it either way exempted a genuinely bare + * `pnpm --filter @cipherstash/prisma-next-example test:e2e` purely because the + * root happens to define `"test:e2e": "turbo run test:e2e"` (#787 review + * follow-up). + */ +const rootScriptDelegatesToTurbo = (task) => + typeof rootScripts[task] === 'string' && /\bturbo\b/.test(rootScripts[task]) + +function workflowRunLines(path) { + const doc = yaml.load(readFileSync(resolve(REPO_ROOT, path), 'utf8')) + const lines = [] + for (const [jobName, job] of Object.entries(doc?.jobs ?? {})) { + for (const step of job?.steps ?? []) { + if (typeof step?.run !== 'string') continue + for (const raw of step.run.split('\n')) { + const line = raw.trim() + if (line) lines.push({ jobName, stepName: step.name, line }) + } + } + } + return lines +} + +describe('workflows route build-dependent turbo tasks through turbo', () => { + it('turbo.json declares the tasks this guard protects', () => { + // If `^build` is dropped from these, the guard below silently stops + // checking anything — assert the premise rather than trusting it. + expect(buildDependentTasks).toContain('typecheck:scaffold') + expect(buildDependentTasks).toContain('typecheck') + expect(buildDependentTasks).toContain('build') + }) + + it('typecheck:scaffold is invoked via turbo, never bare', () => { + const invocations = workflowRunLines(SCAFFOLD_WORKFLOW).filter( + ({ line }) => invokedTask(line)?.task === 'typecheck:scaffold', + ) + + // A bare `pnpm --filter stash run typecheck:scaffold` matches + // `invokedTask` but not `routedThroughTurbo`, so it would fail here. + // Deleting the step entirely is caught by this length assertion. + expect( + invocations.length, + `${SCAFFOLD_WORKFLOW} must run typecheck:scaffold — the scaffold fixtures are only typechecked there`, + ).toBeGreaterThan(0) + + for (const { jobName, stepName, line } of invocations) { + expect( + invokedTask(line).routed, + `${SCAFFOLD_WORKFLOW} job "${jobName}" step "${stepName}" runs typecheck:scaffold without turbo:\n ${line}\nThe scaffold fixtures import @cipherstash/stack/v3, so this needs that package BUILT. Use \`pnpm exec turbo run typecheck:scaffold --filter stash\`.`, + ).toBe(true) + } + }) + + it('no new bare invocation of a build-dependent turbo task in any workflow', () => { + const offenders = WORKFLOWS.flatMap((file) => + workflowRunLines(file) + .filter(({ line }) => { + const invoked = invokedTask(line) + if (!invoked || invoked.routed) return false + if (!buildDependentTasks.has(invoked.task)) return false + // A filtered invocation runs the package's own script, so the root + // script's turbo delegation is irrelevant to it. + if (!invoked.filtered && rootScriptDelegatesToTurbo(invoked.task)) + return false + return !KNOWN_BARE.has(line) + }) + .map( + ({ jobName, stepName, line }) => + `${file} / ${jobName} / ${stepName}: ${line}`, + ), + ) + + expect( + offenders, + `These steps run a turbo task declaring \`dependsOn: ["^build"]\` without turbo, so nothing guarantees their workspace dependencies are built. They pass only while an earlier step in the same job happens to build them. Route them through \`pnpm exec turbo run <task> --filter <pkg>\`.`, + ).toEqual([]) + }) + + it('the grandfathered bare invocations still exist as written', () => { + // KNOWN_BARE entries are matched by exact string. If a step is reworded or + // fixed, its entry goes stale and silently exempts nothing — or worse, + // masks a different line later. Fail so the list gets pruned. + // Searched across every workflow, not just tests.yml: the allowlist now + // exempts lines found anywhere, so a narrower staleness check would let an + // entry keep exempting a step it no longer describes. + const lines = new Set( + WORKFLOWS.flatMap((file) => workflowRunLines(file)).map( + ({ line }) => line, + ), + ) + for (const bare of KNOWN_BARE) { + expect( + lines.has(bare), + `KNOWN_BARE entry is stale — no workflow step runs:\n ${bare}\nRemove it from the allowlist.`, + ).toBe(true) + } + }) +}) diff --git a/scripts/lint-no-dead-package-paths.mjs b/scripts/lint-no-dead-package-paths.mjs new file mode 100644 index 000000000..321b30e04 --- /dev/null +++ b/scripts/lint-no-dead-package-paths.mjs @@ -0,0 +1,206 @@ +import { execFileSync } from 'node:child_process' +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' + +const REPO_ROOT = resolve(import.meta.dirname, '..') + +// Default targets — the surfaces a reader or agent navigates by. Override with +// argv[2..] for tests / ad-hoc checks. +// +// Deliberately NOT scanned: +// - `docs/plans/`, `docs/superpowers/` — design archives. They narrate the +// state of the tree at the time they were written, including packages the +// plan itself proposed removing. Rewriting history there is wrong. +// - `CHANGELOG.md` (anywhere) — released entries name what they removed. +const TARGETS = process.argv.slice(2).length + ? process.argv.slice(2) + : [ + 'AGENTS.md', + 'CLAUDE.md', + 'README.md', + 'SECURITY.md', + 'CONTRIBUTE.md', + 'docs', + '.github', + 'skills', + 'e2e/README.md', + 'packages/cli/AGENTS.md', + // The linters themselves carry package paths — an allowlist entry for a + // deleted package sat here unnoticed because `scripts/` was not scanned. + // `__tests__` is excluded below: its fixtures MUST name dead packages. + 'scripts', + ] + +const SKIP_DIRS = new Set([ + 'node_modules', + 'dist', + 'plans', + 'superpowers', + '.git', + // This linter's own self-tests deliberately reference deleted packages — + // in the fixtures, and in the prose comments of the test files themselves. + // Scanning them would make the suite unrunnable. + '__tests__', +]) +const SKIP_FILES = new Set(['CHANGELOG.md']) +const TEXT_EXT = /\.(md|ya?ml|json|mjs|ts|txt)$/ + +// `packages/<name>` where `<name>` is a real directory name. The character +// class excludes `*`, so workspace globs (`packages/*`, `./packages/*`) are +// left alone, and it is greedy so a longer directory name is never excused by +// a live package whose name is a prefix of it. +// +// The name must END on an alphanumeric. Without that anchor a sentence-final +// `packages/stack.` — or a hyphen at a line wrap, or a trailing underscore — +// captured the punctuation too and reported a LIVE package as dead, failing +// the build with a message naming a directory that plainly exists. Uppercase +// is admitted so a capitalised directory name is checked rather than silently +// skipped; no package uses one today, which is exactly why nothing noticed +// (#772 review, finding 15). +const PACKAGE_REF = /packages\/([a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?)/g + +// Live packages come from git, not from what is on disk. +// +// `readdirSync` was wrong in the direction that matters: deleting a package +// leaves its `dist/` and `node_modules/` behind, so the directory still exists +// and every reference to the deleted package passed. That is the exact failure +// this linter was written to catch, and it silently stopped catching it on any +// checkout where the package had previously been built — two packages deleted +// by this very stack are sitting on `main` right now as exactly such shells +// (#772 review, finding 15). +// +// Note this deliberately does NOT require a `package.json`: `packages/utils` has +// none (it is two loose files consumed by relative path from `packages/nextjs`) +// yet is tracked, live, and referenced from AGENTS.md. +// +// Shelling out to git is a dependency this linter has to own: git missing, or a +// tree with no `.git`, must not read as "every package is dead". +function gitPackagePaths(...args) { + try { + return execFileSync('git', args, { + cwd: REPO_ROOT, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }) + .split('\0') + .filter(Boolean) + } catch (err) { + const detail = String(err.stderr || err.message || '').trim() + console.error( + `Could not list packages via \`git ${args.join(' ')}\`:\n\n ${detail}\n\n` + + 'This linter derives the live package set from git, so it cannot run\n' + + 'without git on PATH or outside a git checkout.', + ) + // Exit 2, not 1: the linter failed to run. Exit 1 means it ran and found + // dead references, which is a different thing to go and fix. + process.exit(2) + } +} + +const livePackages = new Set( + [ + ...gitPackagePaths('ls-files', '-z', 'packages'), + // Untracked but not ignored. A package scaffolded a minute ago is live + // even though nothing about it is staged yet, and reporting it as "does + // not exist" is the sentence-final false alarm all over again, pointed the + // other way. `--directory` is deliberately NOT passed: it collapses an + // all-ignored directory to a single entry, which would resurrect exactly + // the `dist/`-and-`node_modules/` shells this linter exists to catch. + ...gitPackagePaths( + 'ls-files', + '--others', + '--exclude-standard', + '-z', + 'packages', + ), + ] + .map((file) => file.split('/')[1]) + .filter(Boolean), +) + +if (livePackages.size === 0) { + console.error( + 'git reported no packages at all under `packages/`. Refusing to run —\n' + + 'every reference would be flagged. Check that `packages/` is present and\n' + + 'not wholly ignored.', + ) + process.exit(2) +} + +function* walk(abs) { + const stat = statSync(abs) + if (stat.isFile()) { + yield abs + return + } + for (const entry of readdirSync(abs, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (SKIP_DIRS.has(entry.name)) continue + yield* walk(join(abs, entry.name)) + } else if (!SKIP_FILES.has(entry.name) && TEXT_EXT.test(entry.name)) { + yield join(abs, entry.name) + } + } +} + +const offenders = [] +for (const target of TARGETS) { + const abs = resolve(REPO_ROOT, target) + let exists = true + try { + statSync(abs) + } catch { + exists = false + } + // A target that isn't there used to be skipped in silence, on the theory + // that the default list covered optional files. It doesn't — all of TARGETS + // is tracked and present — and the silence was the same rot this linter + // exists to catch, one level up: rename a target and it drops out of + // coverage forever with a green build. `lint-no-hardcoded-runners.mjs` + // already exits 2 on a stale allowlist entry; this is that rule applied to + // this linter's own configuration. + if (!exists) { + console.error( + `Target \`${target}\` does not exist.\n\n` + + 'Either it was renamed or removed — in which case update TARGETS in\n' + + 'this script, since a target that is silently skipped is coverage\n' + + 'quietly lost — or it was mistyped on the command line.', + ) + // Exit 2, not 1: the linter could not check what it was asked to check. + process.exit(2) + } + + for (const file of walk(abs)) { + // A target outside the repo (only reachable via an argv override — every + // default is repo-relative) renders as a `../../../../..` chain climbing + // out of the root, which buries the filename it exists to point at. + const rel = relative(REPO_ROOT, file) + const shown = rel.startsWith('..') ? file : rel + if (SKIP_FILES.has(rel.split('/').pop())) continue + const lines = readFileSync(file, 'utf8').split('\n') + lines.forEach((line, idx) => { + PACKAGE_REF.lastIndex = 0 + for (const m of line.matchAll(PACKAGE_REF)) { + if (livePackages.has(m[1])) continue + offenders.push( + `${shown}:${idx + 1}: \`packages/${m[1]}\` does not exist`, + ) + } + }) + } +} + +if (offenders.length > 0) { + console.error( + `Found ${offenders.length} reference(s) to a non-existent package directory:\n`, + ) + for (const o of offenders) console.error(` ${o}`) + console.error( + '\nA package was renamed or removed without updating the docs and config\n' + + 'that point at it. Repoint each reference at the surviving path, or drop\n' + + 'the line if it no longer describes anything. Design archives\n' + + '(docs/plans, docs/superpowers) and CHANGELOGs are exempt — they record\n' + + 'history, not the current tree.', + ) + process.exit(1) +} diff --git a/scripts/lint-no-hardcoded-runners.mjs b/scripts/lint-no-hardcoded-runners.mjs index c513a28ed..3070963c8 100644 --- a/scripts/lint-no-hardcoded-runners.mjs +++ b/scripts/lint-no-hardcoded-runners.mjs @@ -9,9 +9,6 @@ const REPO_ROOT = resolve(import.meta.dirname, '..') const ALLOWLISTED_PATHS = new Set([ 'packages/wizard/src/lib/detect.ts', // npm row of the PM table 'packages/cli/src/commands/init/utils.ts', // runnerCommand `case 'npm'` - 'packages/cli/src/commands/init/lib/setup-prompt.ts', // execCommand `case 'npm':` switch - 'packages/protect/src/bin/runner.ts', // Pre-allowlisted: helper for Task 11 - 'packages/drizzle/src/bin/runner.ts', // Pre-allowlisted: helper for Task 13 'scripts/lint-no-hardcoded-runners.mjs', // this script's own docs ]) @@ -70,13 +67,77 @@ function isAllowedRunnerSwitch(line) { return /\bcase\s+['"]npm['"]/.test(line) || /name:\s*['"]npm['"]/.test(line) } +// An allowlist entry is a standing exemption, so it has to keep earning its +// place. Two ways it rots: the file is deleted (which is how the legacy Drizzle +// package's `src/bin/runner.ts` entry outlived its package by two months), or +// the file survives but the `npx` literal it was excused for moves elsewhere — +// leaving an entry that reads as evidence the exemption is still needed. Check +// both before scanning anything. +const staleAllowlist = [] +for (const rel of ALLOWLISTED_PATHS) { + let source + try { + source = readFileSync(resolve(REPO_ROOT, rel), 'utf8') + } catch { + staleAllowlist.push(`${rel}: no such file`) + continue + } + const stillNeeded = source + .split('\n') + .some( + (line) => + NPX_TOKEN.test(line) && + !isCommentLine(line) && + !isAllowedFallback(line) && + !isAllowedRunnerSwitch(line), + ) + if (!stillNeeded) { + staleAllowlist.push( + `${rel}: no longer contains an unexcused \`npx\` literal`, + ) + } +} + +if (staleAllowlist.length > 0) { + console.error( + `Found ${staleAllowlist.length} stale allowlist entr(ies) in this script:\n`, + ) + for (const s of staleAllowlist) console.error(` ${s}`) + console.error( + '\nDrop the entry. An exemption for a file that no longer exists, or that\n' + + 'no longer contains the literal it was excused for, is dead weight that\n' + + 'reads as deliberate.', + ) + // Exit 2, not 1: the linter's own configuration is wrong, which is a + // different thing to fix than an `npx` literal in the codebase. + process.exit(2) +} + const offenders = [] for (const target of TARGETS) { const abs = resolve(REPO_ROOT, target) - const stat = statSync(abs) + let stat + try { + stat = statSync(abs) + } catch { + // Was an uncaught throw: exit 1 plus a raw stack trace, which reads to + // anything checking the exit code exactly like a genuine `npx` finding. + // Exit 2 says the linter could not run — the same contract the allowlist + // self-check above already uses. + console.error( + `Target \`${target}\` does not exist.\n\n` + + 'Update the scan roots in this script if it was renamed or removed,\n' + + 'or check the path passed on the command line.', + ) + process.exit(2) + } const files = stat.isDirectory() ? walk(abs) : [abs] for await (const file of files) { + // `rel` stays repo-relative for the allowlist lookup; only the rendering + // falls back to the absolute path, for a target outside the repo root that + // would otherwise print a `../../../../..` chain instead of a filename. const rel = relative(REPO_ROOT, file) + const shown = rel.startsWith('..') ? file : rel if (ALLOWLISTED_PATHS.has(rel)) continue if (/\.(test|spec)\.(ts|tsx|mts|cts)$/.test(file)) continue const lines = readFileSync(file, 'utf8').split('\n') @@ -86,7 +147,7 @@ for (const target of TARGETS) { if (isCommentLine(line)) return if (isAllowedFallback(line)) return if (isAllowedRunnerSwitch(line)) return - offenders.push(`${rel}:${idx + 1}: ${line.trim()}`) + offenders.push(`${shown}:${idx + 1}: ${line.trim()}`) }) } } diff --git a/scripts/lint-typecheck-scope.mjs b/scripts/lint-typecheck-scope.mjs new file mode 100644 index 000000000..51198fb02 --- /dev/null +++ b/scripts/lint-typecheck-scope.mjs @@ -0,0 +1,165 @@ +/** + * A `typecheck` gate must compile SOURCE, and only source. + * + * TypeScript's default `exclude` is `["node_modules", "bower_components", + * "jspm_packages"]` plus `outDir` — it does NOT cover `dist/`. So a tsconfig + * with no `include`, no `exclude` and no `outDir` globs `**\/*`, which sweeps the + * package's own build output into the program alongside its source. + * + * That makes a CI gate non-deterministic in a way that is invisible when it + * passes. `turbo run typecheck --filter <pkg>` only builds the package's + * DEPENDENCIES (`dependsOn: ["^build"]`), not the package itself, so whether + * `dist/` exists during the gate depends on what an unrelated earlier CI step + * happened to build transitively. Reorder the steps and the gate silently + * compiles a different set of files. Locally, after a full build, it compiles a + * different set again. + * + * `skipLibCheck` hides most of the consequences — a stale `.d.ts` importing a + * deleted package still exits 0 — so this cannot be left to "CI is green". + * + * Fix by scoping the tsconfig: either an explicit `include` naming the source + * roots, or an `exclude` listing `dist`. Note that specifying `exclude` REPLACES + * the default list, so `node_modules` must be re-listed alongside `dist`. + */ + +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' + +const REPO_ROOT = resolve(import.meta.dirname, '..') + +// Roots holding workspace members, mirroring `pnpm-workspace.yaml`. Override +// with argv[2..] for tests / ad-hoc checks (each arg is a package directory). +const WORKSPACE_ROOTS = ['packages', 'examples'] + +/** Every directory that looks like a workspace member. */ +function discoverPackages() { + const found = [] + for (const root of WORKSPACE_ROOTS) { + const abs = resolve(REPO_ROOT, root) + if (!existsSync(abs)) continue + for (const entry of readdirSync(abs, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + found.push(join(abs, entry.name)) + } + } + const e2e = resolve(REPO_ROOT, 'e2e') + if (existsSync(e2e)) found.push(e2e) + return found +} + +const targets = process.argv.slice(2).length + ? process.argv.slice(2).map((t) => resolve(REPO_ROOT, t)) + : discoverPackages() + +/** + * Strip comments and trailing commas so `JSON.parse` accepts a tsconfig. + * + * Scans character by character tracking string state rather than pattern + * matching: these tsconfigs map `"@/*": ["../stack/src/*"]`, and a regex for + * block comments happily eats from the `/*` inside that key to the next `*\/`, + * silently destroying the file it was meant to read. + */ +function readJsonc(path) { + const raw = readFileSync(path, 'utf8') + let out = '' + let inString = false + let escaped = false + + for (let i = 0; i < raw.length; i++) { + const ch = raw[i] + + if (inString) { + out += ch + if (escaped) escaped = false + else if (ch === '\\') escaped = true + else if (ch === '"') inString = false + continue + } + + if (ch === '"') { + inString = true + out += ch + continue + } + if (ch === '/' && raw[i + 1] === '/') { + while (i < raw.length && raw[i] !== '\n') i++ + out += '\n' + continue + } + if (ch === '/' && raw[i + 1] === '*') { + i += 2 + while (i < raw.length && !(raw[i] === '*' && raw[i + 1] === '/')) i++ + i++ + continue + } + out += ch + } + + return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1')) +} + +const offenders = [] + +for (const pkgDir of targets) { + const pkgJsonPath = join(pkgDir, 'package.json') + const tsconfigPath = join(pkgDir, 'tsconfig.json') + if (!existsSync(pkgJsonPath) || !existsSync(tsconfigPath)) continue + + let pkgJson + let tsconfig + try { + pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) + tsconfig = readJsonc(tsconfigPath) + } catch (err) { + offenders.push( + `${relative(REPO_ROOT, tsconfigPath)}: could not be parsed — ${err.message}`, + ) + continue + } + + // Only packages wired as a gate. A tsconfig nothing runs is an editor + // setting, not a CI contract. + const scripts = pkgJson.scripts ?? {} + const gate = + scripts.typecheck ?? + (scripts.build === 'tsc --noEmit' ? scripts.build : undefined) + if (gate === undefined) continue + + // An explicit `include` scopes the program by itself. + if (Array.isArray(tsconfig.include) && tsconfig.include.length > 0) continue + + // Otherwise `exclude` has to carry it. `outDir` also excludes by default, but + // these gates all set `noEmit`, so relying on it would be a trap. + const exclude = Array.isArray(tsconfig.exclude) ? tsconfig.exclude : [] + const excludesDist = exclude.some( + (e) => typeof e === 'string' && /(^|\/)dist(\/|$|\*)/.test(e), + ) + if (excludesDist) continue + + offenders.push( + `${relative(REPO_ROOT, tsconfigPath)}: \`${pkgJson.name}\` runs a typecheck gate ` + + `(\`${gate}\`) but the tsconfig declares neither an \`include\` nor an ` + + '`exclude` covering `dist`, so the gate compiles its own build output', + ) +} + +if (offenders.length > 0) { + console.error( + `Found ${offenders.length} typecheck gate(s) that compile build output as well as source:\n`, + ) + for (const o of offenders) console.error(` ${o}`) + console.error( + "\nTypeScript's default `exclude` does not cover `dist/`, so a tsconfig with\n" + + "no `include` globs `**/*` and sweeps the package's own emitted `.d.ts`\n" + + 'and `.js` into the gate. Whether `dist/` exists during CI depends on what\n' + + 'an earlier, unrelated step built transitively — so the gate compiles a\n' + + 'different program in CI than it does locally, and silently changes if the\n' + + 'steps are reordered.\n\n' + + 'Add to the tsconfig:\n\n' + + ' "exclude": ["dist", "node_modules"]\n\n' + + '(`exclude` REPLACES the default list, so `node_modules` must be re-listed.)\n' + + 'Or give it an explicit `include` naming the source roots, as `e2e` and\n' + + '`examples/prisma` do.', + ) + process.exit(1) +} diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index 430b41952..4a279964b 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -199,7 +199,7 @@ export default defineConfig({ | Option | Required | Default | Purpose | |---|---|---|---| | `databaseUrl` | yes | — | PostgreSQL connection string | -| `client` | no | `./src/encryption/index.ts` | Encryption client, loaded by `db validate`, `schema build`, `encrypt *` | +| `client` | no | `./src/encryption/index.ts` | Encryption client, loaded by `db push`, `db validate`, `encrypt backfill` (`schema build` only *writes* here; `encrypt cutover`/`drop` resolve against the database) | Resolved by walking up from `process.cwd()`, like `tsconfig.json`. `stash init` scaffolds it; `stash eql install` offers to. @@ -221,7 +221,7 @@ Seven mechanical steps, no agent handoff. It prompts only when it can't pick a s 1. **Authenticate** — silent when a valid token exists. 2. **Resolve database** — per the resolution order above; verifies the connection. 3. **Resolve proxy choice** — CipherStash Proxy or direct SDK access (the default). Stored as `usesProxy` in `context.json`. Set by `--proxy` / `--no-proxy`; non-TTY without a flag defaults to SDK. -4. **Build schema** — auto-detects Drizzle (`drizzle.config.*`, `drizzle-orm`/`drizzle-kit`), Supabase (from the `DATABASE_URL` host), and Prisma Next. Writes a placeholder encryption client; prompts only if a file already exists there. +4. **Build schema** — auto-detects Drizzle (`drizzle.config.*`, `drizzle-orm`/`drizzle-kit`), Supabase (from the `DATABASE_URL` host), and Prisma Next. Writes a placeholder encryption client; prompts only if a file already exists there. The placeholder declares one sentinel table, `__stash_placeholder__`, because `Encryption` requires at least one — replace it with the tables you actually encrypt. Until you do, the commands that read the client file — `db push`, `db validate`, and `encrypt backfill` — exit 1 and point back at it. 5. **Install dependencies** — one combined prompt for `@cipherstash/stack` and `stash`. Skipped when both are present. 6. **Install EQL** — always EQL v3. **Drizzle** projects generate a v3 install migration (the same output as `eql migration --drizzle`, including the `cs_migrations` tracking schema) so the install lands in your migration history — apply it with `drizzle-kit migrate`; requires `drizzle-kit` to be installed and configured. **Prisma Next** is skipped (it installs EQL via `prisma-next migrate`). Everything else runs `eql install` directly against the resolved database, and is skipped when EQL is already installed. 7. **Gather context** — detects available coding agents and writes `.cipherstash/context.json`. @@ -352,9 +352,9 @@ Gets a project from zero to installed EQL. It loads an existing `stash.config.ts | `--force` | Reinstall even if EQL is present | | `--dry-run` | Show what would happen | | `--supabase` | Supabase-compatible install (no operator families; grants `anon`, `authenticated`, `service_role`) | -| `--drizzle` | Generate a Drizzle migration (`--name`, `--out` tune it — `--name` accepts letters, numbers, `-`, `_` only; `--out` is passed to `drizzle-kit --out`, so set it to match your `drizzle.config.ts`) | -| `--migration` / `--direct` | Supabase: write a migration file, or run SQL directly | -| `--migrations-dir <path>` | Supabase migrations directory (default `supabase/migrations`) | +| `--drizzle` | Generate a Drizzle migration (**v2 only — requires `--eql-version 2`**; for v3 use `eql migration --drizzle`). `--name`, `--out` tune it — `--name` accepts letters, numbers, `-`, `_` only; `--out` is passed to `drizzle-kit --out`, so set it to match your `drizzle.config.ts` | +| `--migration` / `--direct` | Supabase: write a migration file, or run SQL directly. `--migration` is **v2 only — requires `--eql-version 2`**; for a v3 install as a migration use `eql migration` | +| `--migrations-dir <path>` | Supabase migrations directory (default `supabase/migrations`). **v2 only — requires `--eql-version 2`** | | `--exclude-operator-family` | Skip operator families (non-superuser roles) | | `--eql-version <2\|3>` | EQL generation. **Default `3`** (the native `public.eql_v3_*` domain schema — the documented approach). `2` is the legacy composite schema. | | `--latest` | Fetch latest EQL from GitHub instead of the bundled copy (**v2 only**) | @@ -392,6 +392,8 @@ Pass exactly one of `--drizzle` / `--prisma`. The generated migration also insta After writing the migration, `--drizzle` sweeps the output directory for sibling migrations containing an in-place `ALTER COLUMN … SET DATA TYPE <eql_v2_encrypted | eql_v3_*>` — drizzle-kit emits these when you change a plaintext column to an encrypted one, and Postgres rejects them (there is no cast from `text`/`numeric` to an EQL type). Each is rewritten into an `ADD COLUMN` + `DROP` + `RENAME` sequence and the rewritten files are listed. This is equivalent to DROP+ADD — it fixes the type but does **not** preserve data — so it is safe **only on an empty table**. On a populated table the new column starts NULL and the old one is dropped in the same migration, destroying the plaintext; do not run it there. Use the staged `stash encrypt` path (add → backfill → cutover → drop) instead. +The sweep is fail-closed: it rewrites a statement only when the same directory also contains the `CREATE TABLE` or `ADD COLUMN` that declared the column, and the column is not already encrypted **in the corpus**. That is a guarantee about what the migration files say, not about the live database — the sweep never queries the database, so a column that has drifted from its migration history (for example, renamed directly by `stash encrypt cutover`, or altered by hand via psql or the Supabase dashboard) can already hold ciphertext while the corpus still describes it as plaintext. A statement it cannot place — the declaration lives in another migration directory, or the history was squashed — is listed as needing review rather than rewritten, because a column of unknown type may already hold ciphertext that the rewrite's `DROP COLUMN` would destroy. Either way, check the column's actual type in the database before applying a flagged or corpus-cleared rewrite by hand. + #### `eql upgrade` The install SQL is safe to re-run — columns and data survive — but it is not fully idempotent: it begins with `DROP SCHEMA IF EXISTS eql_v3 CASCADE`, which cascade-drops any **functional indexes** built on the `eql_v3` extractors (see `stash-indexing`). After an upgrade, recreate them: migration runners skip migrations already recorded as applied, so add a *new* migration that re-issues the `CREATE INDEX` statements (or run the DDL directly), then `ANALYZE` the affected tables. `upgrade` checks the current version, re-runs the install SQL, and reports the new one. If EQL isn't installed it points you at `eql install`. Same `--supabase`, `--exclude-operator-family`, `--eql-version`, `--latest`, `--dry-run`, `--database-url` flags. @@ -433,7 +435,7 @@ For AI-guided integration that edits your existing schema files in place, prefer The database-side toolset that takes an existing plaintext column the rest of the way, **after** the rollout PR is deployed and dual-writes are live. It drives `@cipherstash/migrate`, recording every transition in `cipherstash.cs_migrations` (installed by `eql install`) and reading intent from `.cipherstash/migrations.json`. -The phase ladder depends on the column's EQL version, which the commands detect from the column's **domain type** (EQL v3 types are self-describing; the `<col>_encrypted` naming is a convention only, never relied upon): +The phase ladder depends on the column's EQL version, which the commands read off the column's **domain type** — never off the `<col>_encrypted` naming, which is a convention only. Detection is one-sided: a `public.eql_v3_*` domain is recognised as v3, and everything else (including a legacy `eql_v2_encrypted` column) falls through to the v2 ladder: - **EQL v3 (the default):** `schema-added → dual-writing → backfilling → backfilled → dropped`. There is no cut-over — the application switches to the encrypted column by name, then the plaintext column is dropped. - **EQL v2:** `schema-added → dual-writing → backfilling → backfilled → cut-over → dropped`, where cut-over renames the encrypted twin into the original column name. @@ -451,10 +453,12 @@ stash encrypt backfill --table users --column email --chunk-size 5000 Chunked, resumable, idempotent. Walks the table in keyset-pagination order, encrypts each chunk via `bulkEncryptModels`, and writes one `UPDATE ... FROM (VALUES ...)` per chunk in a transaction that also checkpoints to `cs_migrations`. SIGINT/SIGTERM finishes the current chunk and exits cleanly; re-running resumes. The `<col> IS NOT NULL AND <col>_encrypted IS NULL` guard makes concurrent runners and re-runs converge. -Backfill **auto-detects the target column's EQL version** from its Postgres domain type and records it (plus the version-appropriate target phase) in `.cipherstash/migrations.json`. On an EQL v3 column it finishes by printing the v3 next steps: switch the application to `<col>_encrypted` by name, then `stash encrypt drop` — there is no cut-over. +Backfill **detects a `public.eql_v3_*` target column as EQL v3** from its Postgres domain type and records it (plus the target phase) in `.cipherstash/migrations.json`. A column that is not a v3 domain — including a legacy `eql_v2_encrypted` one — does not classify and takes the v2 lifecycle, recording no version. On an EQL v3 column it finishes by printing the v3 next steps: switch the application to `<col>_encrypted` by name, then `stash encrypt drop` — there is no cut-over. **Dual-write precondition.** The application must already write both `<col>` and `<col>_encrypted` on every insert and update. Otherwise rows written *during* the backfill land in plaintext only, silently. The first run prompts (interactive) or requires `--confirm-dual-writes-deployed` (non-interactive), then records `dual_writing`. Resumes don't re-prompt. +**Credential precondition — run the backfill with the *application's* credentials.** Backfill encrypts through whichever `CS_*` credentials are in its environment, and EQL index terms derive from the ZeroKMS client key. Backfilling from a laptop on the local device profile, then querying from an app using credentials minted by `stash env`, produces rows that decrypt correctly and **never match a query** — with no error. Export the target environment's `CS_*` values in the shell running the backfill. See [`env`](#env) and `stash-edge` § The Credential-Identity Rule. + | Flag | Description | |---|---| | `--table` / `--column` | Required | @@ -471,11 +475,11 @@ Backfill **auto-detects the target column's EQL version** from its Postgres doma stash encrypt cutover --table users --column email ``` -**EQL v2 only** — v3 has no cut-over: the application switches to the encrypted column by name. Running this command on a **backfilled** v3 column reports "not applicable" (exit 0) with the next step; before backfill completes it exits 1 and says to finish the backfill (never "switch now" onto a half-populated column). For v2, the preconditions are: the column is in the `backfilled` phase, **and** a pending EQL configuration exists (on a v3-only database — no `eql_v2_configuration` table — it explains that and exits 1). +**EQL v2 only** — v3 has no cut-over: the application switches to the encrypted column by name. Running this command on a **backfilled** v3 column reports "not applicable" (exit 0) with the next step — *provided the encrypted column can be identified*. If it was resolved only by elimination (the table's one remaining EQL column, with no `encryptedColumn` recorded in `.cipherstash/migrations.json` and no `<col>_encrypted` name match), it refuses and exits 1 rather than report an outcome for a column it guessed. `.cipherstash/` is gitignored, so a clone or CI runner without that manifest can hit this on a table whose encrypted column is named unconventionally; re-run `stash encrypt backfill … --encrypted-column <name>` to record the pairing; before backfill completes it exits 1 and says to finish the backfill (never "switch now" onto a half-populated column). For v2, the preconditions are: the column is in the `backfilled` phase, **and** a pending EQL configuration exists (on a v3-only database — no `eql_v2_configuration` table — it explains that and exits 1). In one transaction it renames `<col>` → `<col>_plaintext` and `<col>_encrypted` → `<col>`, advances the pending config to `encrypting`, activates it, and appends a `cut_over` event. With a Proxy URL configured (`--proxy-url` or `CIPHERSTASH_PROXY_URL`) it then calls `eql_v2.reload_config()` so Proxy picks up the new shape. -> **After cutover, `<col>` holds ciphertext — the read path is not automatic.** Wire reads through the encryption client (`decryptModel(row, table)` for Drizzle, the `encryptedSupabaseV3` wrapper for Supabase — `encryptedSupabase` on the legacy v2 surface, otherwise `decrypt` / `bulkDecryptModels`) before returning values to callers. Skip this and your read paths hand raw EQL payloads to end users. The integration skill has the exact API. **CipherStash Proxy is the one exception** — it decrypts on the wire, so Proxy users need no application change. The cutover plan written by `stash plan` includes this read-path switch as an explicit step. +> **After cutover, `<col>` holds ciphertext — the read path is not automatic.** Wire reads through the encryption client (`decryptModel(row, table)` for Drizzle, the `encryptedSupabase` wrapper for Supabase, otherwise `decrypt` / `bulkDecryptModels`) before returning values to callers. Skip this and your read paths hand raw EQL payloads to end users. The integration skill has the exact API. **CipherStash Proxy is the one exception** — it decrypts on the wire, so Proxy users need no application change. The cutover plan written by `stash plan` includes this read-path switch as an explicit step. > > **Known gap (v2).** The pending-configuration precondition is satisfied by `stash db push`. SDK-only users (who otherwise never need `db push`) must therefore run it once before `encrypt cutover`. This gap is specific to the legacy v2 path and is not being decoupled — EQL v3 columns sidestep it entirely (no configuration table, no cut-over; see above), which is how [issue #585](https://github.com/cipherstash/stack/issues/585) was resolved when v3 became the default. @@ -514,6 +518,14 @@ CS_CLIENT_KEY=<hex> CS_CLIENT_ACCESS_KEY=CSAK… ``` +> **Every writer of a searchable column must use these same credentials** — +> including `stash encrypt backfill`, seed scripts, and admin tools — or their +> rows decrypt but never match a query. EQL index terms derive from the ZeroKMS +> client key, so a row written under one credential and queried under another +> decrypts correctly and silently fails every search. Mint one credential per +> environment and export it for **every** process that writes that +> environment's data. See `stash-edge` § The Credential-Identity Rule. + Things to know: - **The access key is shown exactly once** — CTS cannot re-reveal it. Pipe the diff --git a/skills/stash-drizzle/SKILL.md b/skills/stash-drizzle/SKILL.md index 7a9b3366a..d9d03b22e 100644 --- a/skills/stash-drizzle/SKILL.md +++ b/skills/stash-drizzle/SKILL.md @@ -1,11 +1,11 @@ --- name: stash-drizzle -description: Integrate CipherStash encryption with Drizzle ORM using @cipherstash/stack-drizzle/v3 (EQL v3). Covers the types.* encrypted column factories (concrete Postgres domains), auto-encrypting query operators (eq, ne, gt/gte/lt/lte, between, inArray, matches, contains, JSON selector, asc/desc), schema extraction, the EncryptionV3 typed client, database setup with stash eql install, and migrating existing plaintext columns to encrypted. Use when adding encryption to a Drizzle ORM project, defining encrypted Drizzle schemas, or querying encrypted columns with Drizzle. +description: Integrate CipherStash encryption with Drizzle ORM using @cipherstash/stack-drizzle (EQL v3). Covers the types.* encrypted column factories (concrete Postgres domains), auto-encrypting query operators (eq, ne, gt/gte/lt/lte, between, inArray, matches, contains, JSON selector, asc/desc), schema extraction, the Encryption typed client, database setup with stash eql install, and migrating existing plaintext columns to encrypted. Use when adding encryption to a Drizzle ORM project, defining encrypted Drizzle schemas, or querying encrypted columns with Drizzle. --- # CipherStash Stack - Drizzle ORM Integration -Guide for integrating CipherStash field-level encryption with Drizzle ORM using `@cipherstash/stack-drizzle/v3` (EQL v3). Provides Drizzle-native encrypted column factories and query operators that transparently encrypt search values — Drizzle never sees plaintext in a query. +Guide for integrating CipherStash field-level encryption with Drizzle ORM using `@cipherstash/stack-drizzle` (EQL v3). Provides Drizzle-native encrypted column factories and query operators that transparently encrypt search values — Drizzle never sees plaintext in a query. In EQL v3 every encrypted column is a **concrete Postgres domain** (`public.eql_v3_text_search`, `public.eql_v3_integer_ord`, ...) whose query capabilities are fixed by the type you pick — there is no capability config object. See the `stash-encryption` skill's "Schema Definition" section (the `types` catalog) for the full catalog and capability suffixes (`Eq`, `Ord`/`OrdOre`, `Match`, `Search`, `Json`). @@ -33,8 +33,8 @@ npm install @cipherstash/stack @cipherstash/stack-drizzle drizzle-orm The Drizzle integration ships as its own first-party package, `@cipherstash/stack-drizzle`, which depends on `@cipherstash/stack`. Install both. -The v3 surface documented here lives on the `@cipherstash/stack-drizzle/v3` subpath. -It is distinct from the older, separate `@cipherstash/drizzle` package (which is +The v3 surface documented here is exported from the `@cipherstash/stack-drizzle` +package root. It is distinct from the older, separate `@cipherstash/drizzle` package (which is `@cipherstash/protect`-based, with different symbol names) — that package is **deprecated and no longer published**; do not install it. This package replaces it. @@ -61,7 +61,7 @@ stash eql migration --drizzle --supabase # also grants eql_v3 to anon/authenti The generated migration also installs the `cs_migrations` tracking schema, so a single `drizzle-kit migrate` covers everything `stash encrypt …` needs — no out-of-band `stash eql install`. EQL v3 ships one SQL bundle for every target including Supabase; `--supabase` only adds the PostgREST/RLS role grants (harmless when you connect directly as `postgres`). Requires `drizzle-kit` installed and configured. -**Changing an existing plaintext column to an encrypted one.** `drizzle-kit generate` emits an in-place `ALTER TABLE … ALTER COLUMN … SET DATA TYPE eql_v3_<name>`, which Postgres rejects — there is no cast from `text`/`numeric` to an EQL domain. (On drizzle-kit 0.31.0 and later the emitted type is also mangled to `"undefined"."eql_v3_<name>"`, since a `customType` has no `typeSchema`.) The `stash eql migration --drizzle` sweep repairs the invalid statement — the `stash-cli` skill covers what it rewrites and the rule that matters: the repair is data-destroying, so it is safe **only on an empty table**. For a table with live data, do **not** apply the swept migration; follow the staged flow in **Migrating an Existing Column to Encrypted** below instead. +**Changing an existing plaintext column to an encrypted one.** `drizzle-kit generate` emits an in-place `ALTER TABLE … ALTER COLUMN … SET DATA TYPE eql_v3_<name>`, which Postgres rejects — there is no cast from `text`/`numeric` to an EQL domain. (On drizzle-kit 0.31.0 and later the emitted type is also mangled to `"undefined"."eql_v3_<name>"`, since a `customType` has no `typeSchema`.) The `stash eql migration --drizzle` sweep repairs the invalid statement — the `stash-cli` skill covers what it rewrites and the rule that matters: the repair is data-destroying, so it is safe **only on an empty table**. It repairs only what it can place: the swept directory must also contain the migration that declared the column. If it does not, the statement is flagged for review instead of rewritten. For a table with live data, do **not** apply the swept migration; follow the staged flow in **Migrating an Existing Column to Encrypted** below instead. ### Column Storage @@ -83,11 +83,11 @@ Encrypted predicates need functional indexes over the `eql_v3.*` extractors, and ## Schema Definition -Use the `types` namespace from `@cipherstash/stack-drizzle/v3` to define encrypted columns. Each factory maps 1:1 to a Postgres domain, and the column's query capabilities are fixed by the type: +Use the `types` namespace from `@cipherstash/stack-drizzle` to define encrypted columns. Each factory maps 1:1 to a Postgres domain, and the column's query capabilities are fixed by the type: ```typescript import { pgTable, integer, timestamp, varchar } from "drizzle-orm/pg-core" -import { types } from "@cipherstash/stack-drizzle/v3" +import { types } from "@cipherstash/stack-drizzle" const usersTable = pgTable("users", { id: integer("id").primaryKey().generatedAlwaysAsIdentity(), @@ -117,37 +117,37 @@ Capability suffixes at a glance (full catalog: `stash-encryption` skill, "Schema Value families: `Integer`/`Smallint`/`Numeric`/`Real`/`Double` (`number`), `Bigint` (`bigint`), `Date`/`Timestamp` (`Date`), `Text` (`string`), `Boolean` (`boolean`, storage only), `Json` (a JSON document — object or array, not a top-level scalar). -`makeEqlV3Column(builder)` wraps a column builder from `@cipherstash/stack/eql/v3` (e.g. `makeEqlV3Column(v3types.TextEq("email"))`) — `types.TextEq("email")` from the Drizzle subpath is shorthand for the same thing. +`makeEqlV3Column(builder)` wraps a column builder from `@cipherstash/stack/eql/v3` (e.g. `makeEqlV3Column(v3types.TextEq("email"))`) — `types.TextEq("email")` from the Drizzle package is shorthand for the same thing. ## Initialization ### 1. Extract Schema from Drizzle Table ```typescript -import { extractEncryptionSchemaV3, createEncryptionOperatorsV3 } from "@cipherstash/stack-drizzle/v3" -import { EncryptionV3 } from "@cipherstash/stack/v3" +import { extractEncryptionSchema, createEncryptionOperators } from "@cipherstash/stack-drizzle" +import { Encryption } from "@cipherstash/stack/v3" // Convert the Drizzle table definition to a CipherStash v3 schema -const usersSchema = extractEncryptionSchemaV3(usersTable) +const usersSchema = extractEncryptionSchema(usersTable) ``` ### 2. Initialize the Encryption Client ```typescript -const encryptionClient = await EncryptionV3({ +const encryptionClient = await Encryption({ schemas: [usersSchema], }) ``` -`EncryptionV3` returns a strongly-typed client: plaintext types are pinned to each column's domain, and query methods only accept queryable columns. +`Encryption` returns a strongly-typed client: plaintext types are pinned to each column's domain, and query methods only accept queryable columns. ### 3. Create Query Operators ```typescript -const ops = createEncryptionOperatorsV3(encryptionClient) +const ops = createEncryptionOperators(encryptionClient) ``` -`createEncryptionOperatorsV3(client, { lockContext, audit })` optionally sets defaults applied to every operand encryption; the async encrypting operators (`eq`, `ne`, `inArray`, `notInArray`, `gt`/`gte`/`lt`/`lte`, `between`/`notBetween`, `matches`, `contains`, and all methods returned by `selector(...)`) also take an optional trailing `{ lockContext, audit }` argument per call. Top-level `asc`/`desc` and the passthrough operators (`isNull`, `isNotNull`, `not`, `and`, `or`, `exists`, `notExists`) encrypt nothing and take no such argument. +`createEncryptionOperators(client, { lockContext, audit })` optionally sets defaults applied to every operand encryption; the async encrypting operators (`eq`, `ne`, `inArray`, `notInArray`, `gt`/`gte`/`lt`/`lte`, `between`/`notBetween`, `matches`, `contains`, and all methods returned by `selector(...)`) also take an optional trailing `{ lockContext, audit }` argument per call. Top-level `asc`/`desc` and the passthrough operators (`isNull`, `isNotNull`, `not`, `and`, `or`, `exists`, `notExists`) encrypt nothing and take no such argument. ### 4. Create Drizzle Instance @@ -377,7 +377,7 @@ if (!decrypted.failure) { Drizzle emits the encrypted query operators, but **no index DDL** — without functional indexes over the `eql_v3.*` term extractors, every encrypted predicate sequential-scans. `encryptedIndexes` derives the recommended indexes for every encrypted column in the table from its domain, so a schema column can't be forgotten: ```typescript -import { encryptedIndexes, types } from "@cipherstash/stack-drizzle/v3" +import { encryptedIndexes, types } from "@cipherstash/stack-drizzle" import { integer, pgTable } from "drizzle-orm/pg-core" export const users = pgTable( @@ -417,13 +417,15 @@ import { index } from "drizzle-orm/pg-core" Run `ANALYZE <table>` after the migration applies — an expression index gathers no statistics at `CREATE INDEX` time. For when to create indexes during a rollout (after backfill, before switching reads), engagement rules, and `EXPLAIN` verification, see the `stash-indexing` skill. +> **Dropping to raw SQL?** ``db.execute(sql`…`)`` bypasses the operators this integration emits, so you own the operand casts yourself — an encrypted predicate needs its needle cast to the column's `eql_v3.query_*` domain, and the driver's parameter-binding rules differ between `pg` and `postgres-js`. The `stash-postgres` skill is the reference for both. + ## Migrating an Existing Column to Encrypted The hard case: a Drizzle table that already exists in production with live data in a plaintext column you want to encrypt. You can't just change the column type — that would drop the data and break NOT NULL constraints. -CipherStash splits this into two named steps with a hard production-deploy gate between them: an **encryption rollout** (schema-add + dual-write code) and a **cutover step** (backfill + switch reads + drop — under EQL v2 the switch is a rename, under v3 it is an application-side change). (If using CipherStash Proxy, the rollout also includes `stash db push` to register the encryption config with EQL.) The `stash-encryption` skill is the canonical reference for the lifecycle; this section walks the Drizzle-specific shape. +CipherStash splits this into two named steps with a hard production-deploy gate between them: an **encryption rollout** (schema-add + dual-write code) and a **cutover step** (backfill + switch reads + drop — under EQL v2 the switch is a rename, under v3 it is an application-side change). (On a legacy EQL v2 + CipherStash Proxy database, the rollout also includes `stash db push` to register the encryption config in `eql_v2_configuration`; EQL v3 ships no configuration table, so on the v3-only database the schema below assumes, `db push` reports "Nothing to do." and a v3 rollout never needs it.) The `stash-encryption` skill is the canonical reference for the lifecycle; this section walks the Drizzle-specific shape. -> **EQL version note.** The CLI rollout tooling (`stash encrypt *`, and the underlying `@cipherstash/migrate`) works with **both EQL versions** and auto-detects a column's version from its Postgres domain type — there is no flag. The lifecycles differ at the end: **v3** (the default, and what the schema below uses) is `schema-add → dual-write → deploy gate → backfill → switch the app to the encrypted column by name → drop`, with **no cut-over rename**; **v2** finishes with `stash encrypt cutover` (a rename swap plus an `eql_v2_configuration` promotion) before the drop. Running `stash encrypt cutover` on a **backfilled** v3 column reports "not applicable" and exits 0 (it exits 1 if the backfill hasn't finished). +> **EQL version note.** The CLI rollout tooling (`stash encrypt *`, and the underlying `@cipherstash/migrate`) works with **both EQL versions** and detects a column's generation from its Postgres domain type — there is no flag. Detection is one-sided: a `public.eql_v3_*` domain classifies as **v3**; anything else — a plaintext column, or a legacy `eql_v2_encrypted` one — classifies as *unknown* and falls through to the **v2** lifecycle, which is the correct default for a v2 column. Only a v3 column has its version recorded in the migration manifest. The lifecycles differ at the end: **v3** (the default, and what the schema below uses) is `schema-add → dual-write → deploy gate → backfill → switch the app to the encrypted column by name → drop`, with **no cut-over rename**; **v2** finishes with `stash encrypt cutover` (a rename swap plus an `eql_v2_configuration` promotion) before the drop. Running `stash encrypt cutover` on a **backfilled** v3 column reports "not applicable" and exits 0 (it exits 1 if the backfill hasn't finished). > **Where am I?** Run `stash status` first (substitute the runner per the note above). It shows you which Drizzle tables/columns are mid-rollout, which are post-deploy, and what the next move is. Re-run after every transition. @@ -451,7 +453,7 @@ Add an `email_encrypted` column **alongside** `email`. Crucially, the encrypted ```typescript // src/db/schema.ts -import { types } from '@cipherstash/stack-drizzle/v3' +import { types } from '@cipherstash/stack-drizzle' export const users = pgTable('users', { id: integer('id').primaryKey().generatedAlwaysAsIdentity(), @@ -464,28 +466,28 @@ Update the encryption client to harvest the encrypted columns from the table: ```typescript // src/encryption/index.ts -import { EncryptionV3 } from '@cipherstash/stack/v3' -import { extractEncryptionSchemaV3 } from '@cipherstash/stack-drizzle/v3' +import { Encryption } from '@cipherstash/stack/v3' +import { extractEncryptionSchema } from '@cipherstash/stack-drizzle' import { users } from '../db/schema' -const usersEncryptionSchema = extractEncryptionSchemaV3(users) +const usersEncryptionSchema = extractEncryptionSchema(users) -export const encryptionClient = await EncryptionV3({ schemas: [usersEncryptionSchema] }) +export const encryptionClient = await Encryption({ schemas: [usersEncryptionSchema] }) ``` -Generate the migration with `drizzle-kit generate`. The generated SQL should be a single `ALTER TABLE ... ADD COLUMN email_encrypted public.eql_v3_text_search;`. Apply with `drizzle-kit migrate`. (This requires the EQL v3 SQL to be installed first — see Database Setup.) +Generate the migration with `drizzle-kit generate`. The generated SQL should be a single `ALTER TABLE ... ADD COLUMN "email_encrypted" "eql_v3_text_search";` — drizzle-kit emits the **bare** domain name, which resolves to the `public.eql_v3_text_search` domain via `search_path` (a schema-qualified custom type would be quoted as one identifier and fail, so the bare name is deliberate). Apply with `drizzle-kit migrate`. (This requires the EQL v3 SQL to be installed first — see Database Setup.) > **Using CipherStash Proxy?** > -> If your app queries encrypted data through CipherStash Proxy, register the new encryption config with EQL: +> `stash db push` registers the encryption config in `eql_v2_configuration`, which only exists on a database that has EQL v2 installed. The Database Setup above installs EQL v3 only, and `types.TextSearch('email_encrypted')` is a `public.eql_v3_text_search` column — v3 keeps a column's config in its domain type and ships no configuration table, so on that database `stash db push` prints "Nothing to do." and exits 0. There is nothing to push for this schema. > > ```bash -> stash db push +> stash db push # EQL v2 + Proxy databases only > ``` > -> If this is the project's first encrypted column, `db push` writes directly to the active EQL config (nothing to rename). If an active config already exists, `db push` writes the new config as `pending` — that's expected. The pending row will be promoted to active by `stash encrypt cutover` in the cutover step. +> On a legacy EQL v2 database: if this is the project's first encrypted column, `db push` writes directly to the active EQL config (nothing to rename). If an active config already exists, `db push` writes the new config as `pending` — that's expected, and `stash db activate` promotes the pending row to active. > -> SDK-only users can skip this step. +> SDK-only users can skip this step on EQL v3 (this section's case) — there is nothing to push. On a legacy EQL v2 column they cannot: `stash encrypt cutover` requires a pending EQL config, so an SDK-only v2 rollout must still run `stash db push` once before cutover (see the SDK-only note under Backfill below). #### Dual-writing: write to both columns from app code @@ -544,49 +546,14 @@ If something goes wrong (e.g. you discover the dual-write code wasn't actually l #### Switch reads to the encrypted column -**EQL v3 (the schema above): there is no cut-over.** The encrypted column keeps -its own name — you switch the application to it by name, verify reads, then drop -the plaintext column. Point your queries at `email_encrypted`, deploy, and -confirm reads decrypt correctly; then skip ahead to the drop step. Running -`stash encrypt cutover` on a **backfilled** v3 column reports "not applicable" and exits 0 (it exits 1 if the backfill hasn't finished). - -The rest of this subsection is the **EQL v2** path (a `eql_v2_encrypted` twin), -kept for existing v2 deployments. - -First, update the Drizzle schema to the post-cutover shape — switch `email` to the encrypted type and remove the `email_encrypted` column. - -> **Using CipherStash Proxy?** -> -> If using Proxy, re-push the encryption config so EQL has a pending row that points at `email` (no `_encrypted` suffix): -> -> ```bash -> stash db push -> # → writes the new config as `pending`. Active config (still pointing at -> # `email_encrypted`) keeps serving while we complete the cutover. -> ``` - -Now run the cutover: - -```bash -stash encrypt cutover --table users --column email -``` - -Inside one transaction it: (1) renames `email` → `email_plaintext` and `email_encrypted` → `email`, (2) promotes the pending EQL config to `active` (and the prior active to `inactive`), (3) records a `cut_over` event in `cs_migrations`. - -The Drizzle schema you just edited now matches the physical DB shape — `email` is the encrypted column. Keep the temporary `email_plaintext: text('email_plaintext')` declaration in the schema file until the drop step: - -```typescript -// src/db/schema.ts (post-cutover) -export const users = pgTable('users', { - id: integer('id').primaryKey().generatedAlwaysAsIdentity(), - email: types.TextSearch('email'), - email_plaintext: text('email_plaintext'), // temporary; dropped next -}) -``` - -App code that does `SELECT email FROM users` now returns ciphertext that must be decrypted via the encryption client. **This is the moment that breaks read paths if they aren't decrypting.** +**EQL v3: there is no cut-over.** The encrypted column keeps its own name — you +switch the application to it by name, verify reads, then drop the plaintext +column. Running `stash encrypt cutover` on a **backfilled** v3 column reports +"not applicable" and exits 0 (it exits 1 if the backfill hasn't finished). -Update read paths to decrypt: +Point your read paths at `email_encrypted` and decrypt the selected envelopes +with the encryption client. **This is the moment that breaks read paths if they +aren't decrypting.** ```typescript // Before @@ -597,10 +564,10 @@ const email = rows[0].email const rows = await db.select().from(users).where(eq(users.id, id)) const decrypted = await encryptionClient.decryptModel(rows[0], usersEncryptionSchema) if (decrypted.failure) throw new Error(decrypted.failure.message) -const email = decrypted.data.email +const email = decrypted.data.email_encrypted ``` -For queries that filter on `email`, switch to the encrypted operators from `createEncryptionOperatorsV3` — `eq`, `matches`, `gte`, etc. (See `## Query Encrypted Data` above.) +For queries that filter on the column, switch to the encrypted operators from `createEncryptionOperators` — `eq`, `matches`, `gte`, etc. (See `## Query Encrypted Data` above.) Deploy, confirm reads decrypt correctly, then drop the plaintext column. #### Drop: remove the plaintext column @@ -610,10 +577,15 @@ Once read paths are updated and you're confident reads are decrypting correctly, stash encrypt drop --table users --column email ``` -The CLI emits a Drizzle migration file with the drop. **Which column it drops depends on the EQL version**, which the CLI auto-detects: - -- **v3** — drops the original plaintext column, `ALTER TABLE users DROP COLUMN email;`. There was no rename, so no `email_plaintext` exists. Requires the column to be in the `backfilled` phase, plus a live coverage check. -- **v2** — drops the post-rename leftover, `ALTER TABLE users DROP COLUMN email_plaintext;`. Requires the `cut-over` phase. +The CLI emits a Drizzle migration file with the drop. For a v3 column it drops +the original plaintext column, `email` — there was no rename, so no +`email_plaintext` exists. The generated SQL is not a bare `ALTER TABLE`: it is a +`DO $stash_drop$` block that takes `LOCK TABLE users IN ACCESS EXCLUSIVE MODE`, +re-counts rows where `email IS NOT NULL AND email_encrypted IS NULL` *at apply +time*, `RAISE EXCEPTION`s if any remain, and only then executes the +`ALTER TABLE ... DROP COLUMN`. So a row written after generation can't be +silently destroyed. Requires the column to be in the `backfilled` phase, plus a +live coverage check at generation time. Review and apply with `drizzle-kit migrate`, then update the schema to its final shape — the encrypted column is the only one left: @@ -678,11 +650,11 @@ All comparison/containment operators auto-encrypt their operands and are async; ### Other v3 Exports -`types`, `makeEqlV3Column`, `getEqlV3Column`, `isEqlV3Column`, `extractEncryptionSchemaV3`, `createEncryptionOperatorsV3`, `EncryptionOperatorError`, and the codec helpers `v3ToDriver` / `v3FromDriver` / `EqlV3CodecError` — all from `@cipherstash/stack-drizzle/v3`. +`types`, `makeEqlV3Column`, `getEqlV3Column`, `isEqlV3Column`, `extractEncryptionSchema`, `createEncryptionOperators`, `EncryptionOperatorError`, and the codec helpers `v3ToDriver` / `v3FromDriver` / `EqlV3CodecError` — all from `@cipherstash/stack-drizzle`. ## Error Handling -Operators throw `EncryptionOperatorError` (exported from `@cipherstash/stack-drizzle/v3`) whenever the query cannot be answered safely: +Operators throw `EncryptionOperatorError` (exported from `@cipherstash/stack-drizzle`) whenever the query cannot be answered safely: - the column is not an encrypted v3 column (there is no plaintext fallback); - the column's domain lacks the operator's capability (e.g. ordering a `TextEq` column, `eq` on a `Json` column); @@ -691,7 +663,7 @@ Operators throw `EncryptionOperatorError` (exported from `@cipherstash/stack-dri - operand encryption itself fails. ```typescript -import { EncryptionOperatorError } from "@cipherstash/stack-drizzle/v3" +import { EncryptionOperatorError } from "@cipherstash/stack-drizzle" class EncryptionOperatorError extends Error { context?: { @@ -706,6 +678,4 @@ There is no `EncryptionConfigError` on the v3 path — capability problems surfa Encryption client operations (`encryptModel`, `bulkDecryptModels`, ...) don't throw — they return `Result` objects with `data` or `failure`. Check `.failure` before using `.data`. -## Legacy: EQL v2 - -The original v2 integration — `encryptedType` config-flag columns, `extractEncryptionSchema`, and `createEncryptionOperators` (with `like`/`ilike`) from the `@cipherstash/stack-drizzle` package root, over the `eql_v2_encrypted` column type installed via `stash eql install --drizzle` (the deprecated standalone `@cipherstash/drizzle` package shipped its own `generate-eql-migration` bin for the same purpose) — still exists for existing deployments and is documented at https://cipherstash.com/docs. New projects must use the `/v3` surface documented above — `stash init --drizzle` generates an EQL **v3** migration via `stash eql migration --drizzle`. Reaching the v2 install path now requires opting in explicitly with `stash eql install --drizzle --eql-version 2`. +> **EQL v2 removed.** `@cipherstash/stack-drizzle` no longer ships an EQL v2 authoring surface: the pre-v3 `encryptedType` config-flag columns and the `like`/`ilike` operators are gone, and the `./v3` subpath has collapsed into the package root (`@cipherstash/stack-drizzle`). All exports documented here are EQL v3. Existing v2 ciphertext is still decryptable via `@cipherstash/stack`; only the Drizzle-side authoring/query-building of new v2 columns is removed. `stash init --drizzle` generates an EQL v3 migration via `stash eql migration --drizzle`. diff --git a/skills/stash-dynamodb/SKILL.md b/skills/stash-dynamodb/SKILL.md index cf3d44d47..d6ba5ed19 100644 --- a/skills/stash-dynamodb/SKILL.md +++ b/skills/stash-dynamodb/SKILL.md @@ -44,18 +44,26 @@ Non-encrypted attributes pass through unchanged. On decryption, the `__source` a ## Choosing a Schema Version -`encryptedDynamoDB` accepts both EQL v3 and EQL v2 tables. **Use EQL v3 for new projects.** +**Encrypt/write is EQL v3 only.** `encryptModel` / `bulkEncryptModels` accept only EQL v3 tables (`types.*` domains). **Decrypt still reads existing EQL v2 items** — `decryptModel` / `bulkDecryptModels` continue to accept an EQL v2 table so previously stored data stays readable. Author all new tables with EQL v3; keep a v2 table around only to read old data. -| | EQL v3 (recommended) | EQL v2 (existing deployments) | +| | EQL v3 (author + read) | EQL v2 (read existing data only) | |---|---|---| | Import | `@cipherstash/stack/v3` | `@cipherstash/stack` + `@cipherstash/stack/schema` | | Schema | `encryptedTable` + `types.*` | `encryptedTable` + `encryptedColumn` | -| Client | `EncryptionV3({ schemas })` | `Encryption({ schemas })` | +| Client | `Encryption({ schemas })` (or the deprecated `EncryptionV3`) | `Encryption({ schemas })` | +| encrypt | ✅ | ❌ removed — write is v3 only | +| decrypt | ✅ | ✅ reads stored v2 items — **native entry only** | | Nested fields | Flat dotted path (`"profile.ssn"`) | Nested group + `encryptedField` | -There is no infrastructure migration between them — DynamoDB has no EQL extension to install and no schema to alter — but there is no automatic *data* migration path either. The two write **different wire formats**, so items written under one version cannot be decrypted by the other. Pick one per table and stay on it; to switch an existing table you must re-encrypt every item. +> **v2 reads need the native entry.** A client from `@cipherstash/stack/wasm-inline` +> (the documented entry for Deno, Bun, Cloudflare Workers and Supabase Edge Functions) +> is EQL v3 only: `encryptedDynamoDB` throws at the call site if you pass it a v2 +> table. Read legacy v2 items with a client from the default `@cipherstash/stack` +> entry. EQL v3 tables work on both entries. -The client must be built for the table. Build the client with the same v3 table you hand to `encryptedDynamoDB` — `EncryptionV3({ schemas: [users] })` (or `Encryption({ schemas: [users], config: { eqlVersion: 3 } })`). Passing a v3 table to a client that never registered it (a client built for a different schema set) throws a clear error naming the table on the first operation, instead of failing later with an opaque FFI deserialization error. +There is no infrastructure migration between the versions — DynamoDB has no EQL extension to install and no schema to alter — and there is no automatic *data* migration either. The two use **different wire formats**; a stored v2 item still decrypts through a v2 table, but new writes are v3. To fully move a table to v3, re-encrypt every item with a v3 schema. + +The client must be built for the table. Build the client with the same v3 table you hand to `encryptedDynamoDB` — `Encryption({ schemas: [users] })` returns the typed v3 client for a concrete v3 schema set. Passing a v3 table to a client that never registered it (a client built for a different schema set) throws a clear error naming the table on the first operation, instead of failing later with an opaque FFI deserialization error. **Nested attributes work in both versions**, with different authoring syntax. @@ -156,29 +164,42 @@ Which domains give you a `__hmac` attribute you can query on: ```typescript import { DynamoDBClient } from "@aws-sdk/client-dynamodb" import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb" -import { EncryptionV3 } from "@cipherstash/stack/v3" +import { Encryption } from "@cipherstash/stack" import { encryptedDynamoDB } from "@cipherstash/stack/dynamodb" const dynamoClient = new DynamoDBClient({ region: "us-east-1" }) const docClient = DynamoDBDocumentClient.from(dynamoClient) -const encryptionClient = await EncryptionV3({ schemas: [users] }) +const encryptionClient = await Encryption({ schemas: [users] }) const dynamo = encryptedDynamoDB({ encryptionClient }) ``` -> **Audit metadata on decrypt:** the client from `EncryptionV3` has no audit -> surface on its decrypt methods, so `.audit()` on `decryptModel` / -> `bulkDecryptModels` resolves normally but the metadata is not recorded. If you -> need audited decrypts, build the client with `Encryption({ schemas: [users], -> config: { eqlVersion: 3 } })` instead — same v3 wire format, chainable decrypt. +> **Audit metadata on decrypt works.** `decryptModel` / `bulkDecryptModels` are +> audit-chainable — `dynamo.decryptModel(item, table).audit({ metadata })` forwards +> the metadata to ZeroKMS, whether the client came from `Encryption` or the +> deprecated `EncryptionV3` alias. (`EncryptionV3` is now a deprecated, type-identical +> alias of `Encryption`; prefer `Encryption`.) -### EQL v2 Schema (existing deployments) +### EQL v2 Schema (reading existing deployments) + +A v2 table is **read-only** through this adapter now: pass it to `decryptModel` / +`bulkDecryptModels` to read items written before the v3 cutover. `encryptModel` / +`bulkEncryptModels` no longer accept a v2 table — author new writes with an EQL v3 +table. + +**Not on the WASM entry.** This whole section requires a client from the default +`@cipherstash/stack` entry. `@cipherstash/stack/wasm-inline` is EQL v3 only — +`Encryption()` there rejects a v2 schema outright, and `encryptedDynamoDB` refuses +the pairing at the call site with a message naming the fix. So on Deno, Bun, +Workers and Supabase Edge Functions, legacy v2 items are not readable through this +adapter; re-encrypt them to a v3 schema, or read them from a Node process using the +native entry. ```typescript import { encryptedTable, encryptedColumn, encryptedField } from "@cipherstash/stack/schema" import { Encryption } from "@cipherstash/stack" -const users = encryptedTable("users", { +const usersV2 = encryptedTable("users", { email: encryptedColumn("email").equality(), // searchable via HMAC name: encryptedColumn("name"), // encrypt-only, no search metadata: encryptedColumn("metadata").dataType("json"), @@ -187,8 +208,11 @@ const users = encryptedTable("users", { }, }) -const encryptionClient = await Encryption({ schemas: [users] }) +const encryptionClient = await Encryption({ schemas: [usersV2] }) const dynamo = encryptedDynamoDB({ encryptionClient }) + +// Read existing v2 items — decrypt still accepts the v2 table. +const decrypted = await dynamo.decryptModel(storedV2Item, usersV2) ``` > **Note:** `encryptedColumn` also supports `.orderAndRange()`, `.freeTextSearch()`, and `.searchableJson()` index methods, but only `.equality()` produces HMAC values usable for DynamoDB key condition queries. @@ -456,7 +480,7 @@ if (result.failure) { import { encryptedDynamoDB } from "@cipherstash/stack/dynamodb" const dynamo = encryptedDynamoDB({ - // From EncryptionV3(...) or Encryption(...) + // From Encryption(...) (or the deprecated EncryptionV3(...) alias) encryptionClient, options: { // optional logger: { error: (message, error) => void }, @@ -467,9 +491,7 @@ const dynamo = encryptedDynamoDB({ ### Instance Methods -`table` is either an EQL v3 table (`types.*` domains) or an EQL v2 one. - -Each method is overloaded on the table's EQL version. +**Encrypt accepts an EQL v3 table only. Decrypt accepts an EQL v3 OR an EQL v2 table** (so stored v2 items stay readable). The decrypt methods are overloaded on the table's EQL version. **EQL v3** — the item is checked against the table's column domains, and the result is typed as the attribute map that is actually stored: a declared column `email` becomes `email__source` (plus `email__hmac` if its domain mints one), NOT `email`. @@ -482,16 +504,14 @@ Each method is overloaded on the table's EQL version. Let `T` be inferred from the argument; do not pass explicit type arguments on the v3 path. -**EQL v2** — unchanged. `T` is the model you pass in, so the result type does NOT reflect the `__source` / `__hmac` split; declare a type of your own to read those attributes. +**EQL v2 — decrypt (read) only.** The v2 write overloads were removed; `encryptModel` / `bulkEncryptModels` no longer accept a v2 table. `decryptModel` / `bulkDecryptModels` still do, so existing v2 items decrypt. `T` is the model shape you expect back; declare a type of your own to read the stored `__source` / `__hmac` attributes. | Method | Signature | Resolves to | |---|---|---| -| `encryptModel` | `(item: T, v2Table)` | `T` | -| `bulkEncryptModels` | `(items: T[], v2Table)` | `T[]` | | `decryptModel` | `(item: Record<string, unknown>, v2Table)` | `T` | | `bulkDecryptModels` | `(items: Record<string, unknown>[], v2Table)` | `T[]` | -All operations are thenable (awaitable) and support `.audit({ metadata })` chaining. See the note in Setup about decrypt-side audit metadata and the `EncryptionV3` client. +All operations are thenable (awaitable) and support `.audit({ metadata })` chaining. On the default `@cipherstash/stack` entry the metadata forwards to ZeroKMS on every operation, encrypt and decrypt alike (see the Setup note). The `@cipherstash/stack/wasm-inline` client has no `.audit()` — its operations return a plain promise — so audit metadata is **dropped** there (logged at debug level). The operation itself still succeeds; only the audit record is lost. Use the native entry when audit trails matter. Types exported from `@cipherstash/stack/dynamodb`: `EncryptedDynamoDBInstance`, `EncryptedDynamoDBConfig`, `EncryptedDynamoDBError`, `AnyEncryptedTable`, `DynamoDBEncryptionClient`, `EncryptedAttributes`, `DecryptedAttributes`, `AuditConfig`. @@ -511,10 +531,11 @@ const result = await encryptionClient.encryptQuery( if (result.failure) throw new Error(result.failure.message) const hmac = result.data.hm // Use this in DynamoDB key conditions -// EQL v2 — pass queryType explicitly: +// EQL v2 — pass queryType explicitly (`usersV2` is the legacy table declared +// in the v2 read section above; `users` is the v3 one and infers its queryType): const v2Result = await encryptionClient.encryptQuery( "search-value", - { table: users, column: users.email, queryType: "equality" } + { table: usersV2, column: usersV2.email, queryType: "equality" } ) if (v2Result.failure) throw new Error(v2Result.failure.message) const v2Hmac = v2Result.data.hm @@ -529,7 +550,8 @@ const v2Hmac = v2Result.data.hm ```typescript import { DynamoDBClient } from "@aws-sdk/client-dynamodb" import { DynamoDBDocumentClient, PutCommand, GetCommand, QueryCommand } from "@aws-sdk/lib-dynamodb" -import { EncryptionV3, encryptedTable, types } from "@cipherstash/stack/v3" +import { Encryption } from "@cipherstash/stack" +import { encryptedTable, types } from "@cipherstash/stack/v3" import { encryptedDynamoDB } from "@cipherstash/stack/dynamodb" // Schema @@ -541,7 +563,7 @@ const users = encryptedTable("users", { // Clients const dynamoClient = new DynamoDBClient({ region: "us-east-1" }) const docClient = DynamoDBDocumentClient.from(dynamoClient) -const encryptionClient = await EncryptionV3({ schemas: [users] }) +const encryptionClient = await Encryption({ schemas: [users] }) const dynamo = encryptedDynamoDB({ encryptionClient }) // Write diff --git a/skills/stash-edge/SKILL.md b/skills/stash-edge/SKILL.md new file mode 100644 index 000000000..12ca34fc3 --- /dev/null +++ b/skills/stash-edge/SKILL.md @@ -0,0 +1,458 @@ +--- +name: stash-edge +description: Run CipherStash encryption on edge and non-Node runtimes with the `@cipherstash/stack/wasm-inline` entry — Deno, Supabase Edge Functions, Cloudflare Workers, and Bun. Covers the import specifier per runtime, the four mandatory `CS_*` variables and minting them with `stash env`, the credential-identity rule (rows written under different credentials decrypt but never match a query), how the WASM client surface differs from the native typed client, and why an EQL v3 schema module cannot be shared across the two entries. Use when adding encryption to a Supabase Edge Function, a Worker, or a Deno service; when a native module fails to load in a deployed runtime; when wiring `CS_*` secrets into an edge deploy; or when encrypted search returns zero rows on the edge but works locally. +--- + +# Encryption on the Edge (WASM entry) + +`@cipherstash/stack` has two runtime entries. The default one binds a +Node-API native module and must be loaded by Node's own `require`. +**`@cipherstash/stack/wasm-inline` is the entry for +everywhere else** — it carries the WASM build of the same engine as a base64 +blob inside the JS, so there is no native binding, no separate `.wasm` fetch, +and nothing for a bundler to externalise. + +This skill covers that entry and the deployment shape around it. It is EQL v3 +throughout. For the SQL that actually queries the encrypted columns — the +predicate forms and driver binding rules — see `stash-postgres`; edge functions +almost always talk to Postgres over a raw driver, so the two are usually read +together. + +## When to Use This Skill + +- Adding encryption to a Supabase Edge Function, Cloudflare Worker, Deno + service, or Bun app. +- A deployed runtime fails to load the native module (`protect-ffi`), or a + bundler chokes trying to include it. +- Wiring `CS_*` credentials into an edge deploy, or minting them at all. +- Encrypted search works locally but returns **zero rows** in the deployed + function — see [The Credential-Identity Rule](#the-credential-identity-rule-a-silent-data-footgun). +- A schema module shared with Node tooling fails to typecheck against the + edge client. + +## Choosing the Entry + +| Runtime | Entry | Why | +|---|---|---| +| Node server, Next.js server code | `@cipherstash/stack` (+ `/v3`) | Native NAPI is faster; the native module must be excluded from bundling — see the [bundling guide](https://cipherstash.com/docs/stack/deploy/bundling) | +| Supabase Edge Functions | `@cipherstash/stack/wasm-inline` | Deno, V8-only, no native modules | +| Cloudflare Workers | `@cipherstash/stack/wasm-inline` | V8 isolate, no native modules | +| Deno (any) | `@cipherstash/stack/wasm-inline` | No NAPI under Deno's default permissions | +| Bun | `@cipherstash/stack/wasm-inline` | Works, and avoids native-module resolution differences | +| Anywhere bundling server code | `@cipherstash/stack/wasm-inline` | Bundles cleanly; nothing to externalise | + +**The WASM entry is ESM-only.** Its `exports` map has an `import` condition +and no `require` — deliberately, since the runtimes it targets are ESM. A CJS +`require('@cipherstash/stack/wasm-inline')` will not resolve. Node consumers +that need it must be ESM (`"type": "module"` or `.mjs`). + +## Importing It + +### Supabase Edge Functions / Deno — `npm:` specifier + +The Edge runtime resolves `npm:` specifiers at function start; there is no +build step. + +```ts +import { + Encryption, encryptedTable, types, isEncrypted, +} from 'npm:@cipherstash/stack@1.0.0-rc.4/wasm-inline' +``` + +**Pin an exact version.** Deno caches by specifier, so an unpinned import +drifts between deploys. And while the package is on a prerelease line, a +caret range does not do what it looks like: `@^1.0.0` will **not** match +`1.0.0-rc.4`, because semver ranges exclude prereleases unless the range +itself names one. Use the exact version, or a prerelease-bearing range +(`@^1.0.0-rc.4`). Check what is current with `npm view @cipherstash/stack dist-tags`. + +### Deno with an import map + +For a project with a `deno.json`, map the specifier once and import the bare +name everywhere: + +```jsonc +{ + "imports": { + "@cipherstash/stack/wasm-inline": "npm:@cipherstash/stack@1.0.0-rc.4/wasm-inline" + } +} +``` + +```ts +import { Encryption, encryptedTable, types } from '@cipherstash/stack/wasm-inline' +``` + +> **No `--allow-ffi` needed.** The whole point of this entry is that nothing +> native loads. If a Deno process running this entry ever demands an FFI +> permission, something has resolved the native entry instead — check the +> import path before granting anything. + +### Cloudflare Workers / Bun / bundlers — normal install + +```bash +npm install @cipherstash/stack +``` + +```ts +import { Encryption, encryptedTable, types } from '@cipherstash/stack/wasm-inline' +``` + +No `externals`, no `nodeExternals`, no `serverExternalPackages` entry. If a +build config already externalises the native module for the default entry, +that config does not apply here and can be left alone. + +## Credentials + +The edge client takes **all four** `CS_*` values explicitly. There is no +credential discovery: `~/.cipherstash` does not exist in a Worker or an Edge +Function container, and there is no device-code login to fall back on. + +```ts +const client = await Encryption({ + schemas: [users], + config: { + workspaceCrn: Deno.env.get('CS_WORKSPACE_CRN')!, + accessKey: Deno.env.get('CS_CLIENT_ACCESS_KEY')!, + clientId: Deno.env.get('CS_CLIENT_ID')!, + clientKey: Deno.env.get('CS_CLIENT_KEY')!, + }, +}) +``` + +Read them from the platform's environment accessor — `Deno.env.get(...)` on +Deno/Supabase, the `env` binding argument on Workers, `process.env` on Bun. + +### Minting them: `stash env` + +```bash +stash env --name my-app-prod # print the four vars to stdout +stash env --name my-app-prod --write # write .env.production.local (mode 0600) +stash env --name edge-dev --write .env.local +``` + +This creates a fresh ZeroKMS client **and** a CipherStash access key from your +local `stash auth login` session. Things that matter here: + +- **The access key is shown exactly once.** Pipe it straight into the secret + store; it cannot be re-revealed. +- **Stdout is pipe-clean** — only the dotenv block goes to stdout, so + `stash env --name x > prod.env` and pipes into secret-store CLIs are safe. +- Each run mints a **new** credential, and duplicate names are rejected. Use a + distinct `--name` per environment. +- `CS_CLIENT_KEY` and `CS_CLIENT_ACCESS_KEY` are secrets. Never commit them; + put placeholder names in `.env.example` instead. + +### Getting them into the runtime + +```bash +# Supabase — local +supabase functions serve --env-file .env.local my-function + +# Supabase — deployed +stash env --name my-app-prod --write .env.production.local +supabase secrets set --env-file .env.production.local + +# Cloudflare Workers +wrangler secret put CS_CLIENT_KEY # repeat per variable + +# Vercel / other platforms +vercel env add CS_CLIENT_KEY production +``` + +## The Credential-Identity Rule (a silent data footgun) + +> **Every writer of a searchable column must use the same credentials as every +> reader — including `stash encrypt backfill`, seed scripts, and admin tools. +> Rows written under different credentials decrypt correctly but never match a +> query.** + +EQL's searchable-encryption index terms (the `hm`, `op`, `bf` fields in the +stored payload) derive from the **ZeroKMS client key**, not from the workspace +or keyset. Two clients in the same workspace with different `CS_CLIENT_ID` / +`CS_CLIENT_KEY` pairs therefore produce **different terms for the same +plaintext**. + +The consequences are asymmetric, which is what makes this hard to spot: + +- **Decryption still works.** The data key is wrapped through ZeroKMS against + the workspace, so any authorised client in the workspace can decrypt the + row. Round-trip tests pass. +- **Search silently fails.** An equality or match predicate compares the + query term against the stored term. Different client keys, different terms, + no match — and no error. The query returns zero rows exactly as though the + data were absent. + +The classic way to hit it: run `stash encrypt backfill` from a laptop (using +the local device-profile credentials), then query those rows from an Edge +Function using `CS_*` values minted by `stash env`. Every row decrypts. No +search ever matches. + +**What to do:** + +- Mint one credential per *environment*, and use it for **every** process that + touches that environment's data — the app, the backfill, seed scripts, admin + jobs, and one-off scripts alike. +- Before running `stash encrypt backfill` against an environment, export that + environment's `CS_*` values into the shell running it. +- If rows have already been written under the wrong credentials, re-encrypt + them with the correct client: read (decryption still works), then write back + through a client built with the target credentials. + +**Diagnosing it:** if `decrypt` returns the right plaintext but an equality +query on the same row returns nothing, compare the stored term against a +freshly minted one for the same plaintext. Matching plaintext with differing +`hm` values is this bug, not an indexing problem. + +## The Client Surface + +`Encryption` from the WASM entry. **Both entries name the factory +`Encryption`**, so the import path is the only thing that distinguishes them — +which makes a stray `import { Encryption } from '@cipherstash/stack'` easy to +miss and confusing to debug, because the two clients take different config and +different bulk shapes. Check the specifier first whenever an edge client +behaves unexpectedly. + +Every fallible method returns the same `{ data } | { failure }` Result +contract as the native client; unwrap before use. + +```ts +const enc = await client.encrypt('alice@example.com', { table: users, column: users.email }) +if (enc.failure) throw new Error(enc.failure.message) + +const dec = await client.decrypt(enc.data) +if (dec.failure) throw new Error(dec.failure.message) +``` + +Available: `encrypt`, `decrypt`, `isEncrypted`, `encryptQuery`, +`encryptQueryBulk`, `bulkEncrypt`, `bulkDecrypt`, `encryptModel`, +`decryptModel`, `bulkEncryptModels`, `bulkDecryptModels`. + +### How it differs from the native typed client + +| | Native (`@cipherstash/stack`) | WASM (`@cipherstash/stack/wasm-inline`) | +|---|---|---| +| Factory | `Encryption({ schemas })` | `Encryption({ schemas, config })` — same name, different module | +| Schema authoring | `encryptedTable` / `types` from `@cipherstash/stack/v3` | the entry's own re-exports (see below) | +| Config | discovered from env / `~/.cipherstash` | all four `CS_*` passed explicitly | +| Typing | signatures derived from the schema | schema-aware, but not the full typed client | +| `.audit()` | chainable on operations | **not available** | +| `.withLockContext()` | chainable on operations | **not available** — see below | +| `bulkEncrypt` shape | `(plaintexts, { table, column })`, `{ id, plaintext }` envelopes | per-item `{ plaintext, table, column }`, plain index-aligned array | +| `decryptModel` / `bulkDecryptModels` | `(model, table, lockContext?)`, **plus** a table-less `(model)` overload for legacy rows | `(model, table)` only — the table is **required** | +| Module format | ESM + CJS | **ESM only** | + +**Authentication and key binding are two different things**, and conflating +them is the standard mistake. Identity-bound encryption needs both: + +1. **Authenticate as the user** — build an `OidcFederationStrategy` (or + `AccessKeyStrategy` for service-to-service) and pass it as + `config.authStrategy`. The client then acts as that user for its lifetime. + **Available on this entry**, and shown below. +2. **Bind the data key to a claim** — chain `.withLockContext({ identityClaim })` + on the operation. *This* is what changes key derivation. **Not available on + this entry** ([#797](https://github.com/cipherstash/stack/issues/797)). + +> [!IMPORTANT] +> **An auth strategy alone does not produce identity-bound data.** It decides +> *who the client is*; a lock context decides *which key the value is encrypted +> under*. Only the first exists here, so on this entry today: +> +> - Values you write are encrypted under the **workspace key**, not the user's +> — even with a per-user `authStrategy`. +> - You **cannot read** anything the native entry wrote under a lock context, +> because decrypt needs the same context. That is a silent split in what the +> two entries can read, on top of the schema incompatibility below. +> +> If a value must be bound to an end-user claim, encrypt and decrypt it on the +> native entry. Don't reach for `as any` to force a lock context through here — +> there is nothing on the other side to receive it. + +The strategy replaced the old per-operation token ceremony +(`LockContext.identify()`, deprecated) — it did **not** replace the lock +context, and the native entry still chains `.withLockContext()` for that. + +```ts +import { Encryption, OidcFederationStrategy } from '@cipherstash/stack/wasm-inline' + +// `create` returns a Result — unwrap it. Passing the Result itself as +// `authStrategy` is the easy mistake, and it fails opaquely later. +const strategy = OidcFederationStrategy.create( + workspaceCrn, // 'crn:<region>:<workspace-id>' + () => getUserJwt(req), // called on every re-federation — Clerk, Supabase Auth, … +) +if (strategy.failure) throw new Error(strategy.failure.error.message) + +const client = await Encryption({ + schemas: [users], + config: { authStrategy: strategy.data, clientId, clientKey }, +}) + +// Authenticated as the end user — but the value is still encrypted under the +// workspace key. There is no `.withLockContext()` on this entry to bind it. +const enc = await client.encrypt('alice@example.com', { + table: users, + column: users.email, +}) +if (enc.failure) throw new Error(enc.failure.message) +``` + +**On the native entry, where lock contexts do exist, the same claim must be +supplied on decrypt.** A value encrypted under a lock context and decrypted +without one — or under a different claim — does not come back. This is the +single most common identity-aware encryption bug, and it does not surface as a +key error; it surfaces as a failed decrypt. It is also why an edge function +cannot read what a lock-context-using Node service wrote. + +`AccessKeyStrategy.create(workspaceCrn, accessKey)` has the same +Result-returning shape, for service-to-service use with a custom token store. +When you pass an auth strategy, do **not** also pass `config.accessKey` — they +are mutually exclusive and the client rejects the combination. + +Construct a client **per request** when using a user-scoped strategy — a +module-level client would bind whichever user happened to arrive first. + +### The bulk shape differs — don't copy the native form + +```ts +// WASM entry: each entry carries its own table and column. +const out = await client.bulkEncrypt([ + { plaintext: 'a@example.com', table: users, column: users.email }, + { plaintext: 'b@example.com', table: users, column: users.email }, +]) +if (out.failure) throw new Error(out.failure.message) +// out.data is index-aligned; a null/undefined plaintext yields null at that index. +``` + +The model helpers (`encryptModel` / `decryptModel` and their bulk forms) *are* +present on this entry, and the traversal is the same one the native entry runs +— declared columns encrypted by JS property name, everything else passing +through, one ZeroKMS round trip per call. + +**The decrypt side has no table-less form.** Both entries take the table as the +second argument, and on both that is the form to prefer. What the native client +*also* offers is a one-arg `decryptModel(model)` overload — the read path for +rows whose table isn't in the schema set, legacy EQL v2 above all, at the cost +of `Date` reconstruction and a precise plaintext shape. This entry has no such +overload: `decryptModel(model, table)` and `bulkDecryptModels(models, table)` +**require** the table, because they resolve date fields from a per-table map +built at client construction. Omitting it throws rather than returning a +`{ failure }`, and a table the client was not initialized with is a defined +failure: + +```ts +const rows = await client.bulkDecryptModels(encryptedRows, users) +if (rows.failure) throw new Error(rows.failure.message) +``` + +A wrapper written against the native signature will therefore compile against +one entry and break on the other — one more reason to author against exactly +one entry (see below). + +## Schema Modules Do Not Cross Entries + +A schema authored with `@cipherstash/stack/v3` **will not typecheck** +against the WASM entry's `Encryption`, and the reverse fails too: + +```text +Type 'EncryptedTextSearchColumn' is not assignable to type 'AnyEncryptedV3Column'. + Types have separate declarations of a private property 'columnName'. +``` + +The two entries ship independent type bundles, and the column classes carry +private fields — which TypeScript compares **nominally**. The declarations are +identical in shape but not the same declaration, so assignment is rejected in +both directions. + +It works fine at runtime, which is the trap: the tempting fix is +`as never` / `as any` on the schema, which silences a real signal and will +keep silencing it after a genuine schema mismatch appears. + +**Author the schema module against exactly one entry, and use that entry's +client with it.** For a project whose encryption runs on the edge, that means +importing `encryptedTable` and `types` from `@cipherstash/stack/wasm-inline` +in the shared schema module: + +```ts +// schema.ts — the single source of truth for this project's schema +import { encryptedTable, types } from '@cipherstash/stack/wasm-inline' + +export const users = encryptedTable('users', { + email: types.TextSearch('email'), + ssn: types.TextEq('ssn'), +}) +``` + +Node-side code that imports this module must then also build its client from +`@cipherstash/stack/wasm-inline` (which runs on Node perfectly well, just with +the WASM engine rather than the native one) and must be ESM. + +If a project genuinely needs the native client on the server *and* the WASM +client on the edge, keep two schema modules and treat their agreement as +something to test, not something the type system will enforce for you. Column +names and domains must match exactly — they are what the database and the +stored payload's `i` identifier are keyed by. + +## Querying from the Edge + +Edge functions rarely have an ORM, so encrypted search is usually hand-written +SQL over `pg` or `postgres-js`. Mint the search needle with `encryptQuery`, +then bind it as a typed parameter: + +```ts +const term = await client.encryptQuery('alice@example.com', { + table: users, column: users.email, queryType: 'equality', +}) +if (term.failure) throw new Error(term.failure.message) + +// postgres-js — bind the unwrapped term, not the Result +const rows = await sql` + SELECT * FROM users + WHERE email = ${sql.json(term.data)}::jsonb::eql_v3.query_text_eq` +``` + +The predicate forms, the per-driver binding rules, and the query-domain names +are the subject of `stash-postgres` — read it before writing the first query. The +two rules that bite immediately: the operand must be **cast to the column's +`eql_v3.query_*` domain**, and on `postgres-js` payloads must be bound with +`sql.json(...)`, never pre-stringified. + +## Troubleshooting + +**`Dynamic require of "..." is not supported` / a native `.node` file in the bundle** +— the native entry got imported. Check every import path resolves to +`@cipherstash/stack/wasm-inline`, including transitive ones from your own +shared modules. + +**`require(...) is not a function` / the specifier won't resolve in CJS** — this +entry is ESM-only. Move the consumer to ESM. + +**Missing `CS_*` at runtime** — the secret store was never populated, or the +function was served without `--env-file`. Validate all four at handler entry +and return an actionable error rather than letting client construction fail +opaquely; the example in `examples/supabase-worker` does exactly this. + +**Encryption works, search returns zero rows** — the credential-identity rule +above. Second most likely: a missing index (see `stash-indexing`) makes it +slow, not empty, so empty results point at credentials or an untyped operand +(`stash-postgres`). + +**Search needle rejected** — free-text needles must be at least 3 characters; +shorter ones tokenize to nothing. + +**Cold-start latency** — the inlined WASM module is compiled on first use. +Construct the client at module scope when the auth strategy is not +user-scoped, so it is reused across invocations on a warm isolate. + +## Reference + +- `stash-postgres` — the raw-SQL predicate cookbook and driver binding rules. +- `stash-encryption` — schema authoring, the `types.*` domain catalog, and the + rollout/cutover lifecycle. +- `stash-cli` — `stash env`, `stash eql install`, `stash encrypt backfill`. +- `stash-indexing` — indexes on encrypted columns (the DDL is the same + wherever the app runs). +- `stash-supabase` — the PostgREST wrapper, for Supabase apps that are not + writing raw SQL. +- Working example: `examples/supabase-worker` in the `cipherstash/stack` repo. +- Bundling guide: https://cipherstash.com/docs/stack/deploy/bundling diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 227df0643..e8016b013 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -1,13 +1,13 @@ --- name: stash-encryption -description: Implement field-level encryption with @cipherstash/stack using the EQL v3 typed schema. Covers the types.* column catalog (concrete Postgres domains with fixed query capabilities), the strongly-typed EncryptionV3 client, encrypt/decrypt and model operations, searchable encryption (equality, free-text, range), encrypted JSON (containment and JSONPath selectors), bulk operations, identity-aware encryption with lock contexts, multi-tenant keysets, and the rollout/cutover lifecycle. Use when adding encryption to a project, defining encrypted schemas, or working with the CipherStash Encryption API. +description: Implement field-level encryption with @cipherstash/stack using the EQL v3 typed schema. Covers the types.* column catalog (concrete Postgres domains with fixed query capabilities), the strongly-typed Encryption client (with EncryptionV3 as a deprecated alias), encrypt/decrypt and model operations, searchable encryption (equality, free-text, range), encrypted JSON (containment and JSONPath selectors), bulk operations, identity-aware encryption with lock contexts, multi-tenant keysets, and the rollout/cutover lifecycle. Use when adding encryption to a project, defining encrypted schemas, or working with the CipherStash Encryption API. --- # CipherStash Stack - Encryption Comprehensive guide for implementing field-level encryption with `@cipherstash/stack`. Every value is encrypted with its own unique key via ZeroKMS (backed by AWS KMS). Encryption happens client-side before data leaves the application. -Encrypted columns are **EQL v3 concrete Postgres domains** (`public.eql_v3_text_search`, `public.eql_v3_integer_ord`, ...): each column's query capabilities are fixed by the domain type you pick in the schema, and the `EncryptionV3` client derives precise TypeScript types from that schema — wrong-typed plaintext is a compile error, not a runtime surprise. +Encrypted columns are **EQL v3 concrete Postgres domains** (`public.eql_v3_text_search`, `public.eql_v3_integer_ord`, ...): each column's query capabilities are fixed by the domain type you pick in the schema, and the `Encryption` client (typed for an all-v3 schema set) derives precise TypeScript types from that schema — wrong-typed plaintext is a compile error, not a runtime surprise. > An older schema surface (EQL v2, with chainable capability builders) still exists for existing deployments — see "Legacy: EQL v2" at the end. Everything else in this skill is the v3 surface. New projects should use v3. @@ -25,13 +25,14 @@ Encrypted columns are **EQL v3 concrete Postgres domains** (`public.eql_v3_text_ ## Quick Start ```typescript -import { EncryptionV3, encryptedTable, types } from "@cipherstash/stack/v3" +import { Encryption } from "@cipherstash/stack" +import { encryptedTable, types } from "@cipherstash/stack/v3" const users = encryptedTable("users", { email: types.TextSearch("email"), }) -const client = await EncryptionV3({ schemas: [users] }) +const client = await Encryption({ schemas: [users] }) const encrypted = await client.encrypt("secret@example.com", { table: users, @@ -145,7 +146,7 @@ profile. ### Programmatic Config ```typescript -const client = await EncryptionV3({ +const client = await Encryption({ schemas: [users], config: { workspaceCrn: "crn:ap-southeast-2.aws:your-workspace-id", @@ -181,16 +182,16 @@ The SDK never logs plaintext data. | Import Path | Provides | |---|---| -| `@cipherstash/stack/v3` | `EncryptionV3` factory, `typedClient`, `TypedEncryptionClient` — plus re-exports of everything in `@cipherstash/stack/eql/v3`. The one-stop import for v3. | +| `@cipherstash/stack/v3` | `Encryption` (the client factory), `EncryptionV3` (a deprecated alias of it), `typedClient`, `TypedEncryptionClient`, `EncryptionClientFor` — plus re-exports of everything in `@cipherstash/stack/eql/v3`. The one-stop import for v3 schema authoring. | | `@cipherstash/stack/eql/v3` | `encryptedTable`, the `types` namespace, `buildEncryptConfig`, inference types (`InferPlaintext`, `InferEncrypted`, `V3ModelInput`, ...) | -| `@cipherstash/stack` | `OidcFederationStrategy`, `AccessKeyStrategy`, the untyped `Encryption` function (also accepts v3 schemas), legacy v2 re-exports | +| `@cipherstash/stack` | `OidcFederationStrategy`, `AccessKeyStrategy`, the `Encryption` function (typed for an all-v3 schema set; nominal for v2/loose schemas), legacy v2 re-exports | | `@cipherstash/stack/identity` | `LockContext` class and identity types | | `@cipherstash/stack/errors` | `EncryptionErrorTypes`, `StackError`, error subtypes, `getErrorMessage` | | `@cipherstash/stack/types` | All TypeScript types | -| `@cipherstash/stack-drizzle/v3` | Drizzle ORM integration for EQL v3 schemas (see the `stash-drizzle` skill) | -| `@cipherstash/stack-supabase` | `encryptedSupabaseV3` wrapper for Supabase (see the `stash-supabase` skill) | -| `@cipherstash/stack/wasm-inline` | The **edge** entry — Deno, Bun, Cloudflare Workers, Supabase Edge Functions. Its own `Encryption` factory plus the v3 authoring surface, `EncryptionErrorTypes`, and the WASM build of protect-ffi inlined into the bundle. No native binding, so no bundler externalisation needed. | -| `@cipherstash/stack/dynamodb` | `encryptedDynamoDB` — **still requires the legacy v2 schema surface**; see "Legacy: EQL v2" below | +| `@cipherstash/stack-drizzle` | Drizzle ORM integration for EQL v3 schemas — the package root, EQL v3 only (see the `stash-drizzle` skill) | +| `@cipherstash/stack-supabase` | `encryptedSupabase` wrapper for Supabase — EQL v3 only (see the `stash-supabase` skill) | +| `@cipherstash/stack/wasm-inline` | The **edge** entry — Deno, Bun, Cloudflare Workers, Supabase Edge Functions. Its own `Encryption` factory plus its own copy of the v3 authoring surface, `EncryptionErrorTypes`, and the WASM build of protect-ffi inlined into the bundle. No native binding, so no bundler externalisation needed. **EQL v3 only** — `Encryption()` here rejects a v2 schema, and its operations return plain Results with no `.audit()` or `.withLockContext()` chaining, so **values written here cannot be identity-bound** and it cannot read what the native entry wrote under a lock context. **ESM-only, and its schema types do not interchange with the other entries'** — see the `stash-edge` skill. | +| `@cipherstash/stack/dynamodb` | `encryptedDynamoDB` — encrypt/write is **EQL v3 only** (`types.*`); decrypt still reads existing v2 items, on the native entry only. See the `stash-dynamodb` skill | | `@cipherstash/stack/schema`, `@cipherstash/stack/client`, `@cipherstash/stack/encryption` | Legacy v2 schema builders and client surface — see "Legacy: EQL v2" below | ## Schema Definition @@ -289,12 +290,13 @@ CREATE TABLE users ( ); ``` -## Client Initialization: `EncryptionV3` +## Client Initialization: `Encryption` -`EncryptionV3` from `@cipherstash/stack/v3` returns a `TypedEncryptionClient` whose method signatures are derived from your schemas — wrong-typed plaintext is rejected at compile time, and query methods only accept queryable columns with `queryType` constrained to the column's capabilities: +`Encryption` from `@cipherstash/stack` is overloaded: hand it an array of concrete EQL v3 tables and it returns a `TypedEncryptionClient` whose method signatures are derived from your schemas — wrong-typed plaintext is rejected at compile time, and query methods only accept queryable columns with `queryType` constrained to the column's capabilities. `encryptedTable` / `types` are imported from `@cipherstash/stack/v3`: ```typescript -import { EncryptionV3, encryptedTable, types } from "@cipherstash/stack/v3" +import { Encryption } from "@cipherstash/stack" +import { encryptedTable, types } from "@cipherstash/stack/v3" const users = encryptedTable("users", { email: types.TextSearch("email"), @@ -302,18 +304,34 @@ const users = encryptedTable("users", { balance: types.BigintEq("balance"), }) -const client = await EncryptionV3({ schemas: [users] }) +const client = await Encryption({ schemas: [users] }) ``` -- The wire format is pinned to EQL v3 (`eqlVersion: 3`); you don't set it yourself. -- `EncryptionV3()` throws on init error (bad credentials, missing config, invalid keyset UUID). At least one schema is required. -- `typedClient(client, ...schemas)` (also exported from `@cipherstash/stack/v3`) wraps an already-built untyped `EncryptionClient` in the typed surface. -- The untyped `Encryption({ schemas })` from `@cipherstash/stack` also accepts v3 tables (it auto-detects them and sets the v3 wire format), but you lose the per-column typing — prefer `EncryptionV3`. **v2 and v3 tables cannot be mixed in one client** — a mixed schema set throws at init. Create separate clients if you need both. +- The wire format is auto-detected as EQL v3 for an all-v3 schema set; you don't set it yourself. +- `Encryption()` throws on init error (bad credentials, missing config, invalid keyset UUID). At least one schema is required. +- `EncryptionV3` (from `@cipherstash/stack/v3`) is a **deprecated, type-identical alias** of `Encryption`, kept for backwards compatibility. New code should use `Encryption`. +- `typedClient(client, ...schemas)` (exported from `@cipherstash/stack/v3`) wraps an already-built untyped `EncryptionClient` in the typed surface, if you built one via a lower-level path. +- **v2 and v3 tables cannot be mixed in one client** — a mixed schema set throws at init. Create separate clients if you need both. +- `schemas` takes any non-empty array of v3 tables — a shared `export const schemas: AnyV3Table[]`, a `ReadonlyArray`, one built at runtime. It does not have to be an array literal. Writing `Encryption({ schemas: [] })` is a compile error, but an array typed `AnyV3Table[]` that is empty at runtime compiles and throws on init instead. +- **To name the client's type, use `EncryptionClientFor<S>`** (from `@cipherstash/stack/v3`), not `Awaited<ReturnType<typeof Encryption>>`. `Encryption` is overloaded and `ReturnType` reads the last overload, so that idiom always resolves to the untyped nominal client: + +```typescript +import { type AnyV3Table, Encryption, type EncryptionClientFor, encryptedTable, types } from "@cipherstash/stack/v3" + +const users = encryptedTable("users", { email: types.TextSearch("email") }) + +// A named schema tuple keeps per-column typing. +let client: EncryptionClientFor<readonly [typeof users]> +client = await Encryption({ schemas: [users] }) + +// Code that is generic over its schemas keeps the typed surface too. +function withClient(c: EncryptionClientFor<readonly AnyV3Table[]>) { /* … */ } +``` ```typescript // Error handling try { - const client = await EncryptionV3({ schemas: [users] }) + const client = await Encryption({ schemas: [users] }) } catch (error) { console.error("Init failed:", error.message) } @@ -372,7 +390,7 @@ if (!enc.failure) { Typed-client model notes: -- `decryptModel` / `bulkDecryptModels` take the **table as a second argument** and return a plain `Promise<Result<...>>` (not a chainable operation) — pass a lock context as the optional third argument instead of chaining `.withLockContext()`. +- `decryptModel` / `bulkDecryptModels` take the **table as a second argument** and return a chainable `AuditableDecryptModelOperation` — await it for the `Result`, or chain `.audit({ metadata })` / `.withLockContext(lockContext)` first. A lock context may instead be passed as the optional third argument; use one form or the other, not both (chaining `.withLockContext()` onto a decrypt that already took a positional lock context throws). - `Date` columns are reconstructed to real `Date` instances on decrypt; `bigint` columns round-trip as native `bigint`. - Nullable schema fields stay nullable through the round trip. @@ -426,7 +444,10 @@ for (const item of decrypted.data) { **On the WASM entry (`@cipherstash/stack/wasm-inline`), the batch shape differs** — do not copy the shape above onto the edge. The `{ data } | { failure }` Result is the same, but there are no `{ id, plaintext }` envelopes: each entry carries its own table and column, and the payload is a plain index-aligned array. > [!IMPORTANT] -> The `client` below is a **different client** from the one used everywhere else in this skill. The edge entry has its own `Encryption` factory — the native `EncryptionV3` client's `bulkEncrypt` takes `(plaintexts, { table, column })` and will fail at runtime if given the per-item shape below. Construct the WASM client explicitly: +> The `client` below is a **different client** from the one used everywhere else in this skill. The edge entry has its own `Encryption` factory — the native `Encryption` client's `bulkEncrypt` takes `(plaintexts, { table, column })` and will fail at runtime if given the per-item shape below. Construct the WASM client explicitly: + +> [!IMPORTANT] +> **The schema is not shareable between entries either.** Note that `encryptedTable` and `types` are imported *from the WASM entry* below, not from `@cipherstash/stack/eql/v3`. The entries ship independent type bundles whose column classes carry private fields, so TypeScript compares them **nominally**: a schema authored on one entry is rejected by the other's client, in both directions (`Types have separate declarations of a private property 'columnName'`). It works at runtime, which makes `as any` the tempting fix — don't. Author the shared schema module against exactly one entry and build that entry's client from it. See the `stash-edge` skill. ```typescript // Deno / Workers / Supabase Edge Functions — note the import path @@ -564,7 +585,7 @@ All values in the array must be non-null. ### On the Wire: Operators and Ordering -Scalar filters compare through each domain's `eql_v3.*` operators (`col = term`, `col > term`, ...), and `ORDER BY` on an encrypted column goes through the ordering extractors — `eql_v3.ord_term(col)` for OPE-backed (`Ord`/`Search`) domains, `eql_v3.ord_term_ore(col)` for `OrdOre`. The Drizzle v3 integration emits all of this for you (including `asc`/`desc`, which emit `ORDER BY eql_v3.ord_term(col)`). Over Supabase/PostgREST, the adapter's `order()` works on OPE-backed ordering columns (plain `*_ord`, `text_ord`, `text_search`) by sorting on the column's `col->op` term; `OrdOre`-flavour (`*_ord_ore`) domains and columns with no ordering term are rejected. See the `stash-drizzle` and `stash-supabase` skills. These same extractor expressions are also what you index — the functional-index recipes are in the `stash-indexing` skill. +Scalar filters compare through each domain's `eql_v3.*` operators (`col = term`, `col > term`, ...), and `ORDER BY` on an encrypted column goes through the ordering extractors — `eql_v3.ord_term(col)` for OPE-backed (`Ord`/`Search`) domains, `eql_v3.ord_term_ore(col)` for `OrdOre`. The Drizzle v3 integration emits all of this for you (including `asc`/`desc`, which emit `ORDER BY eql_v3.ord_term(col)`). Over Supabase/PostgREST, the adapter's `order()` works on OPE-backed ordering columns (plain `*_ord`, `text_ord`, `text_search`) by sorting on the column's `col->op` term; `OrdOre`-flavour (`*_ord_ore`) domains and columns with no ordering term are rejected. See the `stash-drizzle` and `stash-supabase` skills. These same extractor expressions are also what you index — the functional-index recipes are in the `stash-indexing` skill. Writing the SQL by hand instead (no ORM, `pg` / `postgres-js`)? The predicate matrix, the `eql_v3.query_*` operand casts, and the per-driver parameter-binding rules are in the `stash-postgres` skill; running on Deno / Workers / Supabase Edge Functions, the `stash-edge` skill. ## Encrypted JSON (`types.Json`) @@ -624,7 +645,8 @@ The client authenticates to ZeroKMS through `config.authStrategy`. Leave it unse ```typescript import { OidcFederationStrategy } from "@cipherstash/stack" -import { EncryptionV3, encryptedTable, types } from "@cipherstash/stack/v3" +import { Encryption } from "@cipherstash/stack" +import { encryptedTable, types } from "@cipherstash/stack/v3" const users = encryptedTable("users", { email: types.TextSearch("email") }) @@ -638,7 +660,7 @@ if (strategy.failure) { throw new Error(`[auth] ${strategy.failure.type}: ${strategy.failure.error.message}`) } -const client = await EncryptionV3({ +const client = await Encryption({ schemas: [users], config: { authStrategy: strategy.data }, }) @@ -679,7 +701,7 @@ Every operation returns a `Result`. Narrow on `.failure` before touching `.data` `identityClaim` is an array of JWT claim *names*, not values: `["sub"]` (the default) or `["sub", "org_id"]`. ZeroKMS resolves each claim's value from the JWT the strategy federated. **The same claim must be supplied to encrypt and decrypt** — it is baked into the data key's tag, so decrypting without it fails with `Failed to retrieve key`. -Lock contexts work with every operation: `encrypt`, `decrypt`, `encryptModel`, `decryptModel`, `bulkEncrypt`, `bulkDecrypt`, `bulkEncryptModels`, `bulkDecryptModels`, `encryptQuery`. On the typed client, `decryptModel` and `bulkDecryptModels` take the lock context as their optional **third argument** (they return a plain `Promise<Result<...>>`, so there is no `.withLockContext()` to chain): +Lock contexts work with every operation: `encrypt`, `decrypt`, `encryptModel`, `decryptModel`, `bulkEncrypt`, `bulkDecrypt`, `bulkEncryptModels`, `bulkDecryptModels`, `encryptQuery`. On the typed client, `decryptModel` and `bulkDecryptModels` additionally accept the lock context as an optional **third argument**, which is the form shown here — `.withLockContext()` chains on them too, but use one or the other, not both: ```typescript const dec = await client.decryptModel(enc.data, users, IDENTITY) @@ -703,13 +725,13 @@ Isolate encryption keys per tenant: ```typescript // By name -const client = await EncryptionV3({ +const client = await Encryption({ schemas: [users], config: { keyset: { name: "Company A" } }, }) // By UUID -const client = await EncryptionV3({ +const client = await Encryption({ schemas: [users], config: { keyset: { id: "123e4567-e89b-12d3-a456-426614174000" } }, }) @@ -728,7 +750,7 @@ const result = await client .audit({ metadata: { action: "create" } }) // optional: audit trail ``` -(The typed client's `decryptModel` / `bulkDecryptModels` are the exception: they return a plain `Promise<Result<...>>` and take the lock context as an argument — see "Model Operations".) +(The typed client's `decryptModel` / `bulkDecryptModels` may also take the lock context as a positional argument instead of chaining — see "Model Operations".) ## Error Handling @@ -803,7 +825,7 @@ try { ## Rolling Encryption Out to Production -> **EQL version note.** The rollout tooling (`stash encrypt *`, `@cipherstash/migrate`) works with **both EQL versions** and auto-detects the column's version from its Postgres domain type — no flag. The lifecycles differ at the end: **v2** finishes with `stash encrypt cutover` (a rename swap plus a config promotion in `eql_v2_configuration`), then drops `<col>_plaintext`. **v3 has no cut-over and no configuration table** — after backfill you point the application at `<col>_encrypted` *by name*, verify reads, then `stash encrypt drop` generates the drop of the original plaintext `<col>`. Running `encrypt cutover` on a v3 column safely reports "not applicable" with the next step. `stash db push`/`db activate` remain v2-only (they manage `eql_v2_configuration`). +> **EQL version note.** The rollout tooling (`stash encrypt *`, `@cipherstash/migrate`) works with **both EQL versions** and detects a column's generation from its Postgres domain type — there is no flag. Detection is one-sided: a `public.eql_v3_*` domain classifies as **v3**; anything else — a plaintext column, or a legacy `eql_v2_encrypted` one — classifies as *unknown* and falls through to the **v2** lifecycle, which is the correct default for a v2 column — *unless* the table also holds EQL v3 columns and `.cipherstash/migrations.json` records an `encryptedColumn` that is one of the unclassified ones. That combination is ambiguous, so `cutover`/`drop` fail closed and name the recorded column instead of guessing. Only a v3 column has its version recorded in the migration manifest. The lifecycles differ at the end: **v2** finishes with `stash encrypt cutover` (a rename swap plus a config promotion in `eql_v2_configuration`), then drops `<col>_plaintext`. **v3 has no cut-over and no configuration table** — after backfill you point the application at `<col>_encrypted` *by name*, verify reads, then `stash encrypt drop` generates the drop of the original plaintext `<col>`. Running `encrypt cutover` on a v3 column safely reports "not applicable" with the next step. `stash db push`/`db activate` remain v2-only (they manage `eql_v2_configuration`). Adding a fresh encrypted column to a table you don't yet write to is the easy case — declare it in the schema, run the migration, start writing. The harder case is taking an **existing plaintext column with live data** and turning it into an encrypted one without dropping a write or returning the wrong value mid-cutover. @@ -834,7 +856,7 @@ Everything that lands in the repo and ships in **one** PR: | Schema-add | Migration adds `<col>_encrypted` (nullable `jsonb`) alongside the existing plaintext column. Plaintext column unchanged; application still writes only plaintext. | | Dual-write code | Application now writes both `<col>` and `<col>_encrypted` on every persistence path that mutates the row, in the same transaction, on every code branch. Reads still come from the plaintext column. | -> **If you use CipherStash Proxy:** After the schema-add, run `stash db push` to register the new column in `eql_v2_configuration`. With no active config yet it writes directly to `active`; with an existing active config it writes `pending` (cutover will promote it). Required for Proxy-based queries. +> **If you use CipherStash Proxy (the EQL v2 path):** `stash db push` and `stash db activate` manage `eql_v2_configuration`, so they only apply to a database that has EQL v2 installed — on a v3-only database (the default) `db push` reports "Nothing to do." and exits 0, and `db activate` errors out. EQL v3 ships no configuration table, so a v3 rollout has nothing to push. On a v2 + Proxy database, run `stash db push` after the schema-add to register the new column. With no active config yet it writes directly to `active`; with an existing active config it writes `pending`, which `stash db activate` promotes to active (the v2 `stash encrypt cutover` also promotes it as part of its rename). Required for Proxy-based queries. **The dual-write definition matters.** "Writes both columns" is not enough. The rule is: every persistence path that mutates this row writes both columns, in the same transaction, on every code branch. A single missed branch — a CSV import, an admin action, a background job, a third-party webhook handler — means rows inserted in production after deploy land in plaintext only, and backfill won't catch them. Grep for every site that writes the plaintext column before declaring rollout complete. @@ -853,11 +875,11 @@ Once dual-writes are recorded as live in `cs_migrations`: | Action | What changes | |---|---| | `stash encrypt backfill` | Walks the table in keyset-pagination order, encrypts each chunk, writes a single transactional `UPDATE` per chunk plus a `cs_migrations` checkpoint. SIGINT-safe; idempotent re-runs converge. | -| Schema rename | Update the schema file: drop the `_encrypted` suffix; switch the original column declaration onto the encrypted type. | -| `stash encrypt cutover` | One transaction: renames `<col>` → `<col>_plaintext`, `<col>_encrypted` → `<col>`, and promotes `pending` → `active`. Application reads of `<col>` now return decrypted ciphertext transparently. | -| Wire reads through the encryption client | Read paths must decrypt before returning the value to callers (`decryptModel(row, table)` for Drizzle; the Supabase wrapper for Supabase; `decrypt`/`bulkDecryptModels` otherwise). Without this step, reads return raw `eql_v2_encrypted` payloads to end users. | -| Remove dual-write code | The plaintext column is now `<col>_plaintext` and is no longer authoritative. Delete the dual-write logic. | -| `stash encrypt drop` | Emits a migration that removes `<col>_plaintext`. Apply with the project's normal migration tooling. | +| Schema rename (**v2 only**) | Update the schema file: drop the `_encrypted` suffix; switch the original column declaration onto the encrypted type. **v3:** there is no rename — leave `<col>_encrypted` under its own name and point the schema/queries at that name instead. | +| `stash encrypt cutover` (**v2 only**) | One transaction: renames `<col>` → `<col>_plaintext`, `<col>_encrypted` → `<col>`, and promotes the `eql_v2_configuration` row `pending` → `active`. Reads of `<col>` through **CipherStash Proxy** are decrypted on the wire; SDK/ORM reads still return ciphertext and need the decrypt path in the next row. **v3:** cutover does not apply — on a backfilled v3 column it reports "not applicable" and exits 0 without changing anything; skip this row. Exception: if the encrypted column was identified only by elimination (no recorded `encryptedColumn`, no `<col>_encrypted` name match), it exits 1 rather than report an outcome for a guess — record the pairing with `stash encrypt backfill … --encrypted-column <name>`. | +| Wire reads through the encryption client | Read paths must decrypt before returning the value to callers (`decryptModel(row, table)` for Drizzle; the Supabase wrapper for Supabase; `decrypt`/`bulkDecryptModels` otherwise). Without this step, reads return raw EQL payloads to end users (a `public.eql_v3_*` jsonb document on v3; an `eql_v2_encrypted` composite on a legacy v2 column). | +| Remove dual-write code | The plaintext column is no longer authoritative — **v2:** it is now `<col>_plaintext` (the cutover renamed it); **v3:** it is still the original `<col>`, since nothing was renamed. Either way, delete the dual-write logic once reads are served from the encrypted column. | +| `stash encrypt drop` | Emits a migration that drops the plaintext column — and *which* column that is depends on the generation. **v2** (precondition: phase `cut-over`): a plain `ALTER TABLE … DROP COLUMN "<col>_plaintext"`. **v3** (precondition: phase `backfilled`): it drops the **original `<col>`** — there is no `<col>_plaintext` — and the generated SQL is a `DO` block that takes `ACCESS EXCLUSIVE` on the table, re-counts rows with `<col>` set and `<col>_encrypted` NULL *at apply time*, and raises instead of dropping if any remain. Apply with the project's normal migration tooling. | **Create the functional indexes between backfill and the read switch** (EQL v3 columns). After `stash encrypt backfill` completes and before reads move to the encrypted column, create the `eql_v3.*` extractor indexes for every queried capability (and `ANALYZE`) — one bulk build instead of per-row maintenance during backfill, and the switched reads engage an index from the first query. Recipes in the `stash-indexing` skill. (Legacy v2 rollouts have no extractor indexes to create — skip this step.) @@ -866,7 +888,7 @@ Once dual-writes are recorded as live in `cs_migrations`: Three sources of truth, kept separate on purpose: - **`.cipherstash/migrations.json`** (repo) — *intent*. Which columns the developer wants to encrypt and at which phase, code-reviewable. -- **`eql_v2_configuration`** (DB, EQL-managed) — *EQL intent*. Which columns are encrypted and with which indexes; drives the CipherStash Proxy. +- **`eql_v2_configuration`** (DB, EQL-managed) — *EQL intent*. **EQL v2 + Proxy only.** Which columns are encrypted and with which indexes; drives the CipherStash Proxy. EQL v3 encodes a column's config in its Postgres domain type and ships no configuration table, so a v3-only database has just the other two. - **`cipherstash.cs_migrations`** (DB, CipherStash-managed) — *runtime state*. Append-only event log: phase transitions, backfill cursors, error rows. Latest row per `(table, column)` is the current state. `stash encrypt status` shows all three side-by-side and flags drift (e.g. EQL says registered, the physical `<col>_encrypted` column is missing). `stash status` (the quest log) rolls them up into the per-column "what's the next move" view used during a rollout. @@ -875,6 +897,51 @@ Three sources of truth, kept separate on purpose: ### CLI sequence for a single column +#### EQL v3 (the default) + +```bash +# Run this often — it's the canonical "where am I?" command. +stash status + +# ---- ENCRYPTION ROLLOUT (one PR, one deploy) ---- +# 1. Add the encrypted twin column via your normal migration tooling +# (drizzle-kit / supabase migrations / etc.). +# 2. Edit application code so every persistence path writes both +# `<col>` and `<col>_encrypted` in the same transaction, on every +# code branch. +# 3. Ship the PR to production. + +# ---- ⛔ DEPLOY GATE ---- +# Verify dual-writes are live, then redraft the plan for cutover work: +stash status +stash plan + +# ---- ENCRYPTION CUTOVER ---- +stash encrypt backfill --table users --column email +# Prompts to confirm dual-writes are live (or pass +# --confirm-dual-writes-deployed in CI). Resumable; SIGINT-safe. + +# Recovery — if dual-writes weren't actually live when backfill ran, +# re-run with --force to encrypt every plaintext row regardless. +stash encrypt backfill --table users --column email --force + +# Create the `eql_v3.*` extractor indexes for every queried capability +# and ANALYZE the table, via your normal migration tooling. Do this +# after backfill and before reads move over. Recipes: `stash-indexing`. + +# Point the application at the encrypted column BY NAME — +# `email_encrypted`. There is no rename and no `stash encrypt cutover` +# on v3. Wire the read paths through the encryption client so they +# decrypt, deploy, and verify reads return plaintext. + +# Then remove the dual-write code and drop the plaintext column. +# The generated migration re-checks coverage under a lock at apply +# time and refuses to drop if any plaintext-only row remains: +stash encrypt drop --table users --column email +``` + +#### EQL v2 (legacy) + > **Known limitation (v2):** `stash encrypt cutover` requires a pending EQL configuration registered via `stash db push`. SDK-only users may hit a "No pending EQL configuration" error. **Workaround:** Run `stash db push` once before `stash encrypt cutover`, even if you don't use CipherStash Proxy. Decoupling cutover from EQL config for SDK users is tracked separately. (EQL v3 columns never hit this — cutover doesn't apply to them.) ```bash @@ -915,9 +982,9 @@ stash encrypt cutover --table users --column email stash encrypt drop --table users --column email ``` -#### If you use CipherStash Proxy +#### EQL v2 + CipherStash Proxy -Register and promote encryption config at each phase: +Register and promote encryption config at each phase. This applies only to a database with EQL v2 installed — `eql_v2_configuration` is where `stash db push` writes, and a v3-only database has no such table (`db push` reports "Nothing to do."). A v3 rollout uses the v3 sequence above whether or not Proxy is in front of it. ```bash # Run this often — it's the canonical "where am I?" command. @@ -929,8 +996,9 @@ stash status # 2. Register the new encryption config with EQL: stash db push # First push (no active config yet) → writes directly to active. -# Subsequent push (active already exists) → writes pending; cutover -# will promote it. +# Subsequent push (active already exists) → writes pending, which +# `stash db activate` promotes — as does the v2 `stash encrypt +# cutover` below, as part of its rename. # 3. Edit application code so every persistence path writes both # `<col>` and `<col>_encrypted` in the same transaction, on every # code branch. @@ -987,7 +1055,7 @@ await runBackfill({ }) ``` -Useful when the backfill needs to run in a worker, on a schedule, or alongside an existing job runner. `runBackfill` is version-agnostic — for an EQL v3 column pass an `EncryptionV3` client (from `@cipherstash/stack/v3`) and it writes v3 envelopes straight into the concrete `eql_v3_*` domain column. +Useful when the backfill needs to run in a worker, on a schedule, or alongside an existing job runner. `runBackfill` is version-agnostic — for an EQL v3 column pass an `Encryption` client built from a v3 schema set and it writes v3 envelopes straight into the concrete `eql_v3_*` domain column. ### Invariants the rollout preserves @@ -1001,10 +1069,10 @@ Useful when the backfill needs to run in a worker, on a schedule, or alongside a | Target | Package / entry point | Skill | |---|---|---| -| Drizzle ORM | `@cipherstash/stack-drizzle/v3` — v3 column factories (each `types.*` factory emits its domain as the column's SQL type for `drizzle-kit generate`), schema extraction, auto-encrypting operators (`ops.eq`, `ops.matches`, `ops.contains`, `ops.selector`, `ops.asc`, ...) | `stash-drizzle` | -| Supabase | `encryptedSupabaseV3` from `@cipherstash/stack-supabase` — schema-aware query builder (`eq`, `matches`, `contains`, `selectorEq`/`selectorNe`, ...) that works through PostgREST, including as `anon` | `stash-supabase` | +| Drizzle ORM | `@cipherstash/stack-drizzle` — v3 column factories (each `types.*` factory emits its domain as the column's SQL type for `drizzle-kit generate`), schema extraction, auto-encrypting operators (`ops.eq`, `ops.matches`, `ops.contains`, `ops.selector`, `ops.asc`, ...) | `stash-drizzle` | +| Supabase | `encryptedSupabase` from `@cipherstash/stack-supabase` — schema-aware query builder (`eq`, `matches`, `contains`, `selectorEq`/`selectorNe`, ...) that works through PostgREST, including as `anon` | `stash-supabase` | | Prisma | `@cipherstash/prisma-next` — searchable field-level encryption for Postgres | — | -| DynamoDB | `encryptedDynamoDB` from `@cipherstash/stack/dynamodb` — **v2 schema surface only**, see the exception note under "Legacy: EQL v2" | `stash-dynamodb` | +| DynamoDB | `encryptedDynamoDB` from `@cipherstash/stack/dynamodb` — encrypt is **EQL v3 only**; decrypt still reads existing v2 items | `stash-dynamodb` | ## Complete API Reference @@ -1017,14 +1085,14 @@ Useful when the backfill needs to run in a worker, on a schedule, or alongside a | `encryptQuery` | `(plaintext, { table, column, queryType?, returnType? })` — queryable columns only; `queryType` constrained to the column's capabilities | `EncryptQueryOperation` | | `encryptQuery` | `(terms: readonly ScalarQueryTerm[])` — batch form | `BatchEncryptQueryOperation` | | `encryptModel` | `(model, table)` — schema fields validated against inferred plaintext types | `EncryptModelOperation<V3EncryptedModel<Table, T>>` | -| `decryptModel` | `(model, table, lockContext?)` | `Promise<Result<V3DecryptedModel<Table, T>, EncryptionError>>` | +| `decryptModel` | `(model, table, lockContext?)` | `AuditableDecryptModelOperation<V3DecryptedModel<Table, T>>` | | `bulkEncryptModels` | `(models, table)` | `BulkEncryptModelsOperation<V3EncryptedModel<Table, T>>` | -| `bulkDecryptModels` | `(models, table, lockContext?)` | `Promise<Result<V3DecryptedModel<Table, T>[], EncryptionError>>` | +| `bulkDecryptModels` | `(models, table, lockContext?)` | `AuditableDecryptModelOperation<V3DecryptedModel<Table, T>[]>` | | `bulkEncrypt` | `(plaintexts, { column, table })` — parity passthrough | `BulkEncryptOperation` | | `bulkDecrypt` | `(encryptedPayloads)` — parity passthrough | `BulkDecryptOperation` | | `getEncryptConfig` | `()` | The client's encrypt config | -The encrypt-side operations are thenable (awaitable) and support `.withLockContext()` and `.audit()` chaining; `decryptModel`/`bulkDecryptModels` return plain promises and take the lock context as an argument. +All of these operations are thenable (awaitable) and support `.withLockContext()` and `.audit()` chaining — including `decryptModel`/`bulkDecryptModels`, which also accept the lock context as a third argument. Use one or the other: chaining `.withLockContext()` onto a decrypt that already took a positional lock context throws. ### Schema Builders @@ -1050,6 +1118,6 @@ const users = encryptedTable("users", { const client = await Encryption({ schemas: [users] }) ``` -The scalar v2 API — `Encryption` plus the `@cipherstash/stack/schema` builders, the v2 Drizzle/Supabase integrations (`@cipherstash/stack-drizzle`, `encryptedSupabase`), and `stash eql install --eql-version 2` — still exists for existing deployments. Legacy v2 `searchableJson()` is the exception: protect-ffi 0.30 cannot emit the removed selector envelope, so migrate those columns to v3 `types.Json`. Full v2 documentation lives at [cipherstash.com/docs](https://cipherstash.com/docs). Remember: v2 and v3 tables cannot be mixed in one client. (If you are migrating code from the old `@cipherstash/protect` package, its `protect`/`csTable`/`csColumn` names map onto this v2 surface.) +**v2 is a read path now, not an authoring surface.** `decrypt` / `decryptModel` still read stored v2 payloads, and `stash eql install --eql-version 2` still installs the v2 SQL, so existing deployments keep working. What is gone: the **Supabase** and **Drizzle** adapters are EQL v3 only — `encryptedSupabase` is the v3 factory (there is no v2 form), and `@cipherstash/stack-drizzle` dropped `encryptedType` and the `like`/`ilike` operators. A v2 column reached through either adapter is read-only; decrypt it through `@cipherstash/stack` instead. The `@cipherstash/stack/schema` builders (`encryptedColumn(...).equality()`, …) remain exported but are `@deprecated` — do not author new schemas with them. **Legacy v2 `searchableJson()`** cannot be emitted by protect-ffi 0.30 (the selector envelope was removed), so migrate those columns to v3 `types.Json`. Full v2 documentation lives at [cipherstash.com/docs](https://cipherstash.com/docs). Remember: v2 and v3 tables cannot be mixed in one client. (If you are migrating code from the old `@cipherstash/protect` package, its `protect`/`csTable`/`csColumn` names map onto this v2 surface.) -> **Exception — DynamoDB.** The DynamoDB integration (`encryptedDynamoDB` from `@cipherstash/stack/dynamodb`) **still requires the v2 schema surface** — `encryptedTable`/`encryptedColumn`/`encryptedField` from `@cipherstash/stack/schema`. Do not use v3 `types.*` schemas with it. See the `stash-dynamodb` skill; v3 support is tracked in [cipherstash/stack#657](https://github.com/cipherstash/stack/issues/657). +> **DynamoDB.** The DynamoDB integration (`encryptedDynamoDB` from `@cipherstash/stack/dynamodb`) now **encrypts EQL v3 only** — author tables with `types.*` from `@cipherstash/stack/eql/v3`. Its decrypt methods still accept a v2 table so previously stored v2 items remain readable. See the `stash-dynamodb` skill. diff --git a/skills/stash-indexing/SKILL.md b/skills/stash-indexing/SKILL.md index 10b362d16..d7f7d981e 100644 --- a/skills/stash-indexing/SKILL.md +++ b/skills/stash-indexing/SKILL.md @@ -254,10 +254,10 @@ Index not being used: **The integrations emit the query operators for you — none applies index DDL on its own. Making sure these indexes exist is always your job.** This skill is the general model — recipes, engagement rules, verification. How to apply it in a specific integration lives in that integration's skill: -- **Drizzle** — `encryptedIndexes(t)` from `@cipherstash/stack-drizzle/v3` derives the recommended indexes for every encrypted column in the table, or declare individual expression indexes in the schema DSL. See `stash-drizzle` § Indexing Encrypted Columns. +- **Drizzle** — `encryptedIndexes(t)` from `@cipherstash/stack-drizzle` derives the recommended indexes for every encrypted column in the table, or declare individual expression indexes in the schema DSL. See `stash-drizzle` § Indexing Encrypted Columns. - **Prisma Next** — Prisma's schema language cannot express functional indexes; the DDL goes in a migration in the adapter's flow. See `stash-prisma-next`. - **Supabase** — a `supabase/migrations/` file; no superuser needed (see above). See `stash-supabase`. -- **Raw SQL / plain PostgreSQL** — the recipes in this skill, in whatever migration tool owns the schema. Never ad-hoc in production. +- **Raw SQL / plain PostgreSQL** — the recipes in this skill, in whatever migration tool owns the schema. Never ad-hoc in production. The predicates those indexes serve are in `stash-postgres`. ## When to Create Indexes During an Encryption Rollout @@ -271,3 +271,5 @@ Index not being used: - `stash-encryption` — the `types.*` domain catalog, wire-format operators and ordering, and the rollout/cutover lifecycle. - `stash-cli` — `stash eql install`, `stash db validate` (its "No indexes on an encrypted column" Info finding is resolved by this skill), `stash encrypt backfill` / `cutover`. - `stash-drizzle`, `stash-supabase`, `stash-prisma-next` — per-integration query patterns; index DDL placement per the section above. +- `stash-postgres` — the hand-written predicate forms these indexes serve (`pg` / `postgres-js`, no ORM). +- `stash-edge` — the WASM entry, for apps whose queries run on Deno / Workers / Supabase Edge Functions. diff --git a/skills/stash-postgres/SKILL.md b/skills/stash-postgres/SKILL.md new file mode 100644 index 000000000..8e3260b32 --- /dev/null +++ b/skills/stash-postgres/SKILL.md @@ -0,0 +1,479 @@ +--- +name: stash-postgres +description: Query EQL v3 encrypted columns from hand-written Postgres SQL over `pg` (node-postgres) or `postgres` (postgres-js) — no ORM. Covers the column-domain-to-query-domain operator matrix (which of `=`, `<>`, `<`, `<=`, `>`, `>=`, `@@`, `@>` each encrypted domain accepts), minting search needles with `encryptQuery`, the per-driver parameter-binding rules for encrypted payloads, and the double-encoding failure that trips the domain CHECK with a message naming neither JSON nor encoding. Use when writing INSERT/SELECT against an encrypted column without an ORM, when a predicate returns zero rows or raises "operator does not exist", or when a domain CHECK constraint rejects an encrypted value on write. Assumes a direct Postgres connection with client-side encryption — CipherStash Proxy encrypts on the wire and needs none of this. +--- + +# Raw Postgres SQL Against Encrypted Columns (EQL v3) + +An EQL v3 encrypted column is a **Postgres domain over `jsonb`** (`public.eql_v3_text_search`, +`public.eql_v3_bigint_ord`, …). Reading and writing it from raw SQL is two rules: + +1. **Writing** — bind the `Encrypted` payload your client produced as a + *JSON object* parameter. How you do that differs per driver, and getting it + wrong trips a domain CHECK with an unhelpful message. +2. **Querying** — never send a plaintext. Mint a **query term** with + `encryptQuery` and cast it to the column's matching `eql_v3.query_*` + domain. That cast is what selects the right operator overload: leave the + operand as bare `jsonb` and you get a *different* overload, one that + expects a full storage envelope. + +This covers the `pg` and `postgres-js` drivers with no ORM — plain Node +services, Hono, edge functions. If you use Drizzle, Prisma Next, or the +Supabase client, those integrations emit correct operands for you: see +`stash-drizzle`, `stash-prisma-next`, `stash-supabase` instead. + +> **Using CipherStash Proxy? None of this applies.** This skill assumes the +> app connects to Postgres **directly** and encrypts **client-side**: Stack +> mints the payloads and the query terms, and your SQL carries them. Through +> [CipherStash Proxy](https://github.com/cipherstash/proxy) the split is the +> opposite — you write *plaintext* SQL and Proxy encrypts on write and +> decrypts on read, so there is no `encryptQuery`, no `eql_v3.query_*` cast, +> and no payload to bind. +> +> Proxy's schema lifecycle — `stash db push` into `eql_v2_configuration`, +> promoted at cutover — belongs to EQL **v2**. This skill is EQL v3, where a +> column's encryption config lives in its own domain and there is no +> configuration table to push. The `stash` CLI does not track a +> Proxy-versus-SDK choice; it targets the direct-connection path this skill +> describes. + +## When to Use This Skill + +- Writing `INSERT` / `UPDATE` / `SELECT` against an encrypted column by hand. +- A predicate returns zero rows, or errors with `operator does not exist`. +- A write fails with `value for domain eql_v3_… violates check constraint`. +- Choosing the right operator for a column's domain. +- Ordering, ranging, or searching inside an encrypted JSON document. + +## The Two Halves + +Assume `users.email` is declared in the database as `public.eql_v3_text_eq` — +a Postgres **domain over `jsonb`**. The domain is the authority: it is what +the column actually *is*, what the CHECK constraint enforces, and what decides +which operators the column admits. + +Everything else is a mapping onto that domain: + +- **`types.TextEq('email')`** — the schema factory from + `@cipherstash/stack/eql/v3` that *declares* the column. A TypeScript builder, + not the column's type. +- **`TextEq` / `TextEqQuery`** — the wire shapes, exported as TypeScript types + by `@cipherstash/eql`. These and the JSON Schemas are generated from the Rust + `eql-bindings` crate, and the SQL bundle is built from the same commit, so + the payload shape and the domain CHECK cannot drift apart. Each type's doc + comment names the domain it maps to and the operators that domain admits. +- **`eql_v3.query_text_eq`** — the *query* domain, also a database type, that a + search needle is cast to. + +That `TextEq` / `TextEqQuery` pairing is precisely the split this section is +about. The domain names below follow the column, so a +`public.eql_v3_text_search` column would use `eql_v3.query_text_search` in +exactly the same places. + +```ts +// 1. Encrypt for storage — the payload includes the ciphertext (`c`). +const enc = await client.encrypt('alice@example.com', { table: users, column: users.email }) +if (enc.failure) throw new Error(enc.failure.message) +await sql`INSERT INTO users (email) VALUES (${sql.json(enc.data)})` + +// 2. Mint a search needle — CIPHERTEXT-FREE, terms only. +const term = await client.encryptQuery('alice@example.com', { + table: users, column: users.email, queryType: 'equality', +}) +if (term.failure) throw new Error(term.failure.message) +const rows = await sql` + SELECT * FROM users WHERE email = ${sql.json(term.data)}::jsonb::eql_v3.query_text_eq` +``` + +Storage payloads and query terms are **different shapes with different +domains**. A storage payload carries `c` (the ciphertext); a query term +deliberately omits it and the `eql_v3.query_*` CHECKs *require* its absence. +Binding a storage payload where a query term belongs fails the CHECK, and vice +versa. + +`queryType` is one of `'equality'`, `'freeTextSearch'`, `'orderAndRange'`, +`'searchableJson'`. Omit it only for single-index columns (`types.TextEq`); +be explicit on multi-index domains like `types.TextSearch`. + +## Naming: Column Domain → Query Domain + +Strip `public.`, insert `query_`, move to the `eql_v3` schema: + +```text +public.eql_v3_text_eq → eql_v3.query_text_eq +public.eql_v3_text_search → eql_v3.query_text_search +public.eql_v3_bigint_ord → eql_v3.query_bigint_ord +public.eql_v3_timestamp_ord → eql_v3.query_timestamp_ord +``` + +**One irregular case:** `types.Json` builds `public.eql_v3_json_search`, but +its query domain is `eql_v3.query_json` — not `query_json_search`. + +| Schema factory | Column domain (`public.`) | Query domain (`eql_v3.`) | +|---|---|---| +| `types.TextEq` | `eql_v3_text_eq` | `query_text_eq` | +| `types.TextMatch` | `eql_v3_text_match` | `query_text_match` | +| `types.TextOrd` | `eql_v3_text_ord` | `query_text_ord` | +| `types.TextSearch` | `eql_v3_text_search` | `query_text_search` | +| `types.<N>Eq` | `eql_v3_<n>_eq` | `query_<n>_eq` | +| `types.<N>Ord` | `eql_v3_<n>_ord` | `query_<n>_ord` | +| `types.<N>OrdOre` | `eql_v3_<n>_ord_ore` | `query_<n>_ord_ore` | +| `types.Json` | `eql_v3_json_search` | **`query_json`** | +| `types.Text`, `types.<N>`, `types.Boolean` | `eql_v3_text` / `eql_v3_<n>` / `eql_v3_boolean` | **none — storage only** | + +`<N>` ranges over `Integer`, `Smallint`, `Bigint`, `Numeric`, `Real`, +`Double`, `Date`, `Timestamp`. The storage-only domains carry no query terms +by design — there is no query domain and nothing to search server-side. + +## The Predicate Matrix + +Which operators each column domain accepts against its query domain. Anything +not listed does not exist as an encrypted operator. + +> **Confirm types against EQL before relying on them.** This table — and every +> other domain/operator table in this skill — is a snapshot of a *versioned* +> surface that is defined elsewhere. Do not treat it as the last word. Consult +> EQL for the precise current types, in the order given under **Where this +> surface is defined** below; the first two checks need nothing but +> `node_modules`. + +| Column domain | Operators | Query domain operand | +|---|---|---| +| `eql_v3_<n>_eq`, `eql_v3_text_eq` | `=` `<>` | `query_<n>_eq` / `query_text_eq` | +| `eql_v3_<n>_ord`, `eql_v3_text_ord` | `=` `<>` `<` `<=` `>` `>=` | `query_<n>_ord` / `query_text_ord` | +| `eql_v3_<n>_ord_ore`, `eql_v3_text_ord_ore` | `=` `<>` `<` `<=` `>` `>=` | `query_<n>_ord_ore` / `query_text_ord_ore` | +| `eql_v3_text_match` | `@@` | `query_text_match` | +| `eql_v3_text_search` | `=` `<>` `<` `<=` `>` `>=` `@@` | `query_text_search` | +| `eql_v3_json_search` | `@>` | `query_json` | +| `eql_v3_json_entry` (from `col -> 'selector'`) | `=` `<>` `<` `<=` `>` `>=` | any `query_<n>_ord` / `query_text_ord` / `query_text_search` | + +Note what is **absent**: there is no `<` on an `_eq` domain, and no `@@` +outside the match-capable text domains. Asking for one raises +`operator does not exist` — which is the good failure. The bad failure is +leaving the operand as bare `jsonb` (see [Traps](#traps)). + +Every operator has a function twin, useful when an operator is awkward to +emit: `eql_v3.eq(col, term)`, `eql_v3.matches(col, term)`, and the comparison +functions. `col = term` and `eql_v3.eq(col, term)` are equivalent. + +### Where this surface is defined + +Nothing in the matrix above is authored by the client library. The domains, +operators, CHECKs, and extractor functions all come from the **EQL SQL +bundle** that `stash eql install` applies — published as the `@cipherstash/eql` +package and developed at +[`cipherstash/encrypt-query-language`](https://github.com/cipherstash/encrypt-query-language). +The CLI pins an exact version, so a database is only ever on one bundle. + +That makes every table in this skill a snapshot of a *versioned* surface. +**Go to EQL for the precise current types rather than trusting these tables +alone** — in this order: + +1. **The EQL skill**, when it is installed. It ships from + `encrypt-query-language` alongside the bundle it documents, so it tracks the + version you actually have, and it is authoritative for domain names, query + domains, operators, and payload shapes. +2. **The generated TypeScript types** in `@cipherstash/eql`. Every per-domain + type's doc comment names its domain and that domain's operators — the + `TextEqQuery` type, for instance, is documented as the + `eql_v3.query_text_eq` equality query operand admitting `=` and `<>`. They + are generated from the same Rust `eql-bindings` commit as the SQL bundle, + so the two cannot disagree. +3. **The install SQL**, shipped at + `@cipherstash/eql/dist/sql/cipherstash-encrypt.sql` (under `node_modules`, + wherever your package manager resolves it). Its `CREATE OPERATOR` + statements are the last word on which overloads exist — each names its + `LEFTARG`, `RIGHTARG`, and implementing `FUNCTION`. +4. **The database itself**, which is the runtime truth and the right check + when a query is failing right now: + + ```sql + SELECT eql_v3.version(); -- which bundle is actually installed + ``` + +The `stash` CLI depends on `@cipherstash/eql` and pins an exact version, so +2 and 3 are available in any project that has the CLI installed, with no +database connection required. + +If an operator is absent from all of these, that is an upstream question, not +a client-library one — the operator set lives in `encrypt-query-language`. + +## Binding Parameters: The Driver Rules + +**This differs between drivers.** Both encrypted payloads and query terms are +plain JS objects, and the two drivers disagree about how to put a JS object +into a `jsonb`-backed domain. + +### `postgres` (postgres-js) — always `sql.json(...)` + +| Binding form | `INSERT` into a domain column | Query operand with `::jsonb::eql_v3.query_*` | +|---|---|---| +| `${sql.json(payload)}` | ✅ | ✅ | +| `${payload}` (bare object) | ❌ `invalid input syntax for type json` | ✅ | +| `${JSON.stringify(payload)}::jsonb` | ❌ CHECK violation | ❌ CHECK violation | + +**Use `sql.json(...)` in both positions** — it is the only form that works in +both, so there is no reason to track which position you are in. + +```ts +await sql`INSERT INTO users (email) VALUES (${sql.json(enc.data)})` +await sql`SELECT * FROM users + WHERE email = ${sql.json(term.data)}::jsonb::eql_v3.query_text_eq` +``` + +### `pg` (node-postgres) — pass the object + +node-postgres serialises a JS object to JSON exactly once, so all three forms +happen to work. Pass the object and let the driver do it: + +```ts +await client.query('INSERT INTO users (email) VALUES ($1)', [enc.data]) +await client.query( + 'SELECT * FROM users WHERE email = $1::eql_v3.query_text_eq', [term.data]) +``` + +Do not pre-stringify even though `pg` tolerates it — it is the one habit that +silently breaks if the project ever moves to `postgres-js`. + +### The double-encoding failure, precisely + +`${JSON.stringify(payload)}::jsonb` on postgres-js produces: + +```text +value for domain eql_v3_text_search violates check constraint "eql_v3_text_search_check" +``` + +The message names neither JSON nor encoding, which is why this one costs an +afternoon. What happened: the explicit `::jsonb` makes postgres-js infer a +`jsonb` parameter, so it JSON-encodes the value — which was *already* a JSON +string. The result is a jsonb **string scalar**, not an object: + +```sql +SELECT jsonb_typeof($1::jsonb) -- 'string', not 'object' +``` + +Every EQL domain CHECK opens with `jsonb_typeof(VALUE) = 'object'`, so it +fails on the very first clause. Diagnose any CHECK-violation-on-write by +running `jsonb_typeof` on the parameter; `'string'` means double-encoded. + +## Query Recipes + +Assume `sql` is a postgres-js tag; for `pg` use numbered placeholders as above. + +**The recipes below omit the `Result` guard for brevity — your code must not.** +`encryptQuery` returns `{ data } | { failure }`, so reading `.data` without +first checking `.failure` binds `undefined` into the query, which fails as a +domain CHECK violation rather than as the encryption error it actually is. +Every recipe should be read as though it were written: + +```ts +const term = await client.encryptQuery(/* … */) +if (term.failure) throw new Error(term.failure.message) +``` + +### Equality + +```ts +const term = await client.encryptQuery(email, { + table: users, column: users.email, queryType: 'equality', +}) +await sql`SELECT * FROM users + WHERE email = ${sql.json(term.data)}::jsonb::eql_v3.query_text_eq` +``` + +On a `types.TextSearch` column the cast is `::eql_v3.query_text_search` — the +query domain always matches the *column's* domain, not the query type. + +### Free-text match + +```ts +// `bio` is a types.TextSearch column here; on a types.TextMatch column the +// cast is ::eql_v3.query_text_match. +const term = await client.encryptQuery('needle', { + table: users, column: users.bio, queryType: 'freeTextSearch', +}) +await sql`SELECT * FROM users + WHERE bio @@ ${sql.json(term.data)}::jsonb::eql_v3.query_text_search` +``` + +Match is **one-sided**: a hit may be a false positive, a miss never is. Filter +client-side after decryption if exactness matters — and never build a negated +match (`NOT (bio @@ …)`), which would drop true rows. Needles must be at least +3 characters; shorter ones tokenize to nothing and are rejected. + +### Range and ordering + +```ts +const term = await client.encryptQuery(new Date('2026-01-01'), { + table: events, column: events.createdAt, queryType: 'orderAndRange', +}) +await sql`SELECT * FROM events + WHERE created_at >= ${sql.json(term.data)}::jsonb::eql_v3.query_timestamp_ord + ORDER BY eql_v3.ord_term(created_at) DESC + LIMIT 20` +``` + +**`ORDER BY` must use the extractor form.** `ORDER BY created_at` sorts the +raw encrypted payload — which is neither meaningful nor index-backed. Sorting +on `eql_v3.ord_term(col)` is both. Ordering is available on `_ord`, +`_ord_ore`, and `text_search` columns; use `ord_term_ore` for `_ord_ore`. + +### Encrypted JSON — containment + +```ts +const needle = await client.encryptQuery({ role: 'admin' }, { + table: users, column: users.prefs, queryType: 'searchableJson', +}) +await sql`SELECT * FROM users + WHERE prefs @> ${sql.json(needle.data)}::jsonb::eql_v3.query_json` +``` + +An **object** value produces a containment needle. Note the containment needle +is a bare `{ sv: [...] }` shape with no version field — unlike the scalar +terms, which are full v3 envelopes. Bind it the same way regardless. + +### Encrypted JSON — field selector + +A **string** value produces a JSONPath selector, and v3 has no encrypted-selector +envelope: `encryptQuery` returns the **bare selector-hash string**. Bind it as +the plain text argument of `->` / `->>`, with no domain cast: + +```ts +const sel = await client.encryptQuery('$.role', { + table: users, column: users.prefs, queryType: 'searchableJson', +}) +await sql`SELECT prefs -> ${sel.data} FROM users` +``` + +The extracted value is an `eql_v3_json_entry`, which accepts the ordering +operators — so a field inside an encrypted document can be ranged and ordered: + +```ts +await sql`SELECT * FROM orders + WHERE data -> ${sel.data} >= ${sql.json(term.data)}::jsonb::eql_v3.query_integer_ord + ORDER BY eql_v3.ord_term(data -> ${sel.data})` +``` + +Field-level `=` between extracted entries is **not** supported (an extracted +entry carries no value selector) — use document containment for exact field +equality. + +## Reading Rows Back + +`SELECT` returns the stored payload as an object; hand it straight to +`decrypt` — do not `JSON.parse` it, and do not cast it to `::jsonb` in the +query (see the projection trap below). + +```ts +const [row] = await sql`SELECT id, email FROM users WHERE id = ${id}` +const dec = await client.decrypt(row.email) +if (dec.failure) throw new Error(dec.failure.message) +``` + +For whole rows, `decryptModel` / `bulkDecryptModels` walk the schema and +decrypt every declared column in one ZeroKMS round trip. They match by **JS +property name**, so a raw `SELECT` returning snake_case DB column names will +not match a schema keyed by camelCase properties — alias in the query +(`SELECT last_login AS "lastLogin"`) or decrypt the columns individually. + +## Traps + +**A bare `::jsonb` operand picks a different operator, not a missing one.** +EQL also defines overloads with `jsonb` on the right — and they coerce that +operand to the **storage** domain: + +```sql +-- what `col = $1::jsonb` actually resolves to: +eql_v3.eq_term(a) = eql_v3.eq_term(b::public.eql_v3_text_search) +``` + +The storage domain's CHECK requires the ciphertext key `c`, which query terms +deliberately omit — so binding a query term without the domain cast raises a +CHECK violation rather than doing what you meant. Those overloads exist so you +can compare against a *full storage envelope* (an already-encrypted value). +For a needle from `encryptQuery`, always cast to the `eql_v3.query_*` domain. + +**Cast to the `query_*` domain, not the column domain.** `$1::public.eql_v3_text_eq` +fails for the same reason — the column domain's CHECK requires `c`. + +**The `value::jsonb` projection trap.** `SELECT email::jsonb … ORDER BY email` +folds the cast into the scan and sorts on `(email)::jsonb`, matching no index. +Project the column raw. + +**`GROUP BY` / `DISTINCT` on the raw column** hashes the whole encrypted +payload (1–2 KB per row) and spills. Group on the extractor — +`GROUP BY eql_v3.eq_term(email)` — which is small and deterministic. + +**Predicates are not indexes.** Everything here works without an index and +sequential-scans. Adding the functional index over the extractor is a separate +step — see `stash-indexing`. + +**Every writer needs the same credentials.** Index terms derive from the +ZeroKMS client key, so rows written by a client with different `CS_*` +credentials decrypt correctly but never match a query — silently. This +includes `stash encrypt backfill` and seed scripts. See `stash-edge` § +The Credential-Identity Rule. + +## Troubleshooting + +**`operator does not exist: public.eql_v3_… = eql_v3.query_…`** — the domain +pair has no such operator. Check the [matrix](#the-predicate-matrix): the +column's domain may not support that predicate (e.g. `<` on an `_eq` column), +or the query domain does not match the column's domain. If the matrix says +the operator *should* exist, check the installed bundle version +(`SELECT eql_v3.version()`) — an older EQL install can predate an overload +documented here. See [Where this surface is defined](#where-this-surface-is-defined). + +**`value for domain … violates check constraint`** on write — double-encoded +payload; run `SELECT jsonb_typeof($1::jsonb)` and see +[above](#the-double-encoding-failure-precisely). On a *query* operand, the +same error usually means a storage payload (with `c`) was bound where a query +term belongs. + +**Zero rows, no error** — in order of likelihood: (1) the rows were written +under different `CS_*` credentials (see the trap above — this is the common +one, and it is completely silent); (2) the column's domain does not carry the +term the predicate needs (a `types.Text` column carries none); (3) a +free-text needle under 3 characters tokenized to nothing. A *missing* domain +cast raises an error rather than returning zero rows, so it is not a candidate +here. + +**Slow but correct** — no index. See `stash-indexing`; confirm with +`EXPLAIN (COSTS OFF)` that the plan shows an `Index Cond` on the extractor +rather than a `Seq Scan`. + +**Checking what a column actually is**, when the schema and the database may +have drifted: + +```sql +SELECT eql_v3.version(); -- which bundle defines the operators available + +SELECT column_name, domain_schema, domain_name + FROM information_schema.columns + WHERE table_name = 'users' AND domain_name LIKE 'eql_v3%'; +``` + +## Reference + +- `stash-encryption` — schema authoring, the `types.*` catalog, `encryptQuery` + and the client API, the rollout/cutover lifecycle. +- `stash-indexing` — functional indexes over the term extractors, and the + `EXPLAIN` checklist. +- `stash-edge` — the WASM entry, `CS_*` credentials, and the + credential-identity rule. +- `stash-cli` — `stash eql install`, `stash db validate`, `stash encrypt backfill`. + +Upstream: + +- **The EQL skill**, shipped from + [`cipherstash/encrypt-query-language`](https://github.com/cipherstash/encrypt-query-language) + — the authority for the current domain, operator, and payload-shape surface. + Consult it in preference to the tables here whenever it is installed; those + tables are a snapshot, it tracks the bundle. +- [`cipherstash/encrypt-query-language`](https://github.com/cipherstash/encrypt-query-language) + — EQL itself: the definition of every domain, operator, CHECK, and extractor + named in this skill, shipped as `@cipherstash/eql`. Operator gaps and + domain-level bugs belong there, not against the client library. +- [`cipherstash/proxy`](https://github.com/cipherstash/proxy) — CipherStash + Proxy, the alternative to this entire skill: plaintext SQL, encryption on + the wire. diff --git a/skills/stash-prisma-next/SKILL.md b/skills/stash-prisma-next/SKILL.md index a19bae603..fef869875 100644 --- a/skills/stash-prisma-next/SKILL.md +++ b/skills/stash-prisma-next/SKILL.md @@ -117,7 +117,7 @@ export const db = postgres<Contract>({ `cipherstashFromStack({ contractJson })` derives the v3 encryption schemas from the contract (one `public.eql_v3_*` domain per column), constructs the -`@cipherstash/stack` `EncryptionV3` client from your `CS_*` env vars or local +`@cipherstash/stack` `Encryption` client from your `CS_*` env vars or local profile, builds the SDK adapter, and returns ready-to-spread `extensions` and `middleware`. @@ -178,7 +178,10 @@ The `ANALYZE` is part of the recipe — an expression index has no statistics until it runs. Works as a non-superuser role (Supabase included); only the ORE-flavour (`_ord_ore`) ordering opclass is superuser-gated. For the full model — which domains take which index, engagement rules, `EXPLAIN` -verification, rollout timing — see the `stash-indexing` skill. +verification, rollout timing — see the `stash-indexing` skill. For encrypted +predicates written as raw SQL rather than through the `cipherstash:*` +operators — operand casts to `eql_v3.query_*`, per-driver parameter binding — +see the `stash-postgres` skill. In a migration, the recipes ride a raw-SQL operation (`rawSql` from `@prisma-next/postgres/migration`) in the migration's `operations`: diff --git a/skills/stash-supabase/SKILL.md b/skills/stash-supabase/SKILL.md index 57f7099da..deacdf19c 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -1,18 +1,21 @@ --- name: stash-supabase -description: Integrate CipherStash encryption with Supabase using @cipherstash/stack-supabase. Covers the encryptedSupabaseV3 wrapper over native EQL v3 column domains, transparent encryption/decryption on insert/update/select, encrypted scalar filters (eq, gt/gte/lt/lte, in, or), ordering on encrypted columns, EQL 3.0.2 PostgREST query-domain limitations, identity-aware encryption, and the complete query builder API. Use when adding encryption to a Supabase project, querying encrypted columns, or building secure Supabase applications. +description: Integrate CipherStash encryption with Supabase using @cipherstash/stack-supabase. Covers the encryptedSupabase wrapper over native EQL v3 column domains, transparent encryption/decryption on insert/update/select, encrypted scalar filters (eq, gt/gte/lt/lte, in, or), ordering on encrypted columns, EQL 3.0.2 PostgREST query-domain limitations, identity-aware encryption, and the complete query builder API. Use when adding encryption to a Supabase project, querying encrypted columns, or building secure Supabase applications. --- # CipherStash Stack - Supabase Integration Guide for integrating CipherStash field-level encryption with Supabase using -the `encryptedSupabaseV3` wrapper over native EQL v3 column domains. The +the `encryptedSupabase` wrapper over native EQL v3 column domains. The wrapper provides transparent encryption on mutations and decryption on selects, with support for equality, range, and ordering. -A legacy EQL v2 wrapper (`encryptedSupabase`) still ships for existing -deployments — see "Legacy: EQL v2" at the end. New projects should use -`encryptedSupabaseV3`. +> **Naming note.** `encryptedSupabase` is the current EQL v3 factory. +> `encryptedSupabaseV3` remains as a `@deprecated`, type-identical alias, so +> existing imports keep working — prefer `encryptedSupabase` in new code. The +> old EQL v2 authoring wrapper (`encryptedSupabase({ encryptionClient, +> supabaseClient }).from(table, schema)`) has been **removed** — see +> "Legacy: EQL v2" at the end. ## When to Use This Skill @@ -53,6 +56,14 @@ this is also how **Supabase Edge Functions** get credentials in local dev — `stash env --name edge-dev --write` and pass `--env-file`, or `supabase secrets set` them for deploys. +> **One credential per environment, used by everything that writes.** EQL index +> terms derive from the ZeroKMS client key, so rows written by a client with +> different `CS_*` values decrypt correctly but never match a query — silently. +> That includes `stash encrypt backfill`, seed scripts, and Edge Functions. +> Encryption *inside* an Edge Function (Deno, no native modules) uses the +> `@cipherstash/stack/wasm-inline` entry — see the `stash-edge` skill; SQL +> written by hand in a migration or RPC is covered by `stash-postgres`. + ### 1. Install EQL v3 on the database ```bash @@ -134,17 +145,17 @@ SQL-standard type names (`integer`, `smallint`, `real`, `double`, `boolean`, ### 3. Initialize the wrapper ```typescript -import { encryptedSupabaseV3 } from "@cipherstash/stack-supabase" +import { encryptedSupabase } from "@cipherstash/stack-supabase" // Introspects the database via options.databaseUrl or DATABASE_URL -const es = await encryptedSupabaseV3(supabaseUrl, supabaseKey) -// or wrap an existing client: await encryptedSupabaseV3(supabaseClient, options) +const es = await encryptedSupabase(supabaseUrl, supabaseKey) +// or wrap an existing client: await encryptedSupabase(supabaseClient, options) await es.from("users").insert({ email: "a@b.com", amount: 30 }) await es.from("users").select("id, email, amount").eq("email", "a@b.com") ``` -`encryptedSupabaseV3` **introspects the database at connect time**: it +`encryptedSupabase` **introspects the database at connect time**: it detects EQL v3 columns by their Postgres domain, derives each column's encryption config from the domain, and builds the encryption client internally — there is no client-side schema to hand-maintain. Introspection @@ -165,7 +176,7 @@ tables against the database at construction: ```typescript import { encryptedTable, types } from "@cipherstash/stack/eql/v3" -import { encryptedSupabaseV3 } from "@cipherstash/stack-supabase" +import { encryptedSupabase } from "@cipherstash/stack-supabase" const users = encryptedTable("users", { email: types.TextSearch("email"), // public.eql_v3_text_search — eq + range + free-text @@ -173,7 +184,7 @@ const users = encryptedTable("users", { joined: types.TimestampOrd("joined_at") // public.eql_v3_timestamp_ord — eq + range, decrypts to Date }) -const es = await encryptedSupabaseV3(supabaseUrl, supabaseKey, { +const es = await encryptedSupabase(supabaseUrl, supabaseKey, { schemas: { users }, }) @@ -443,7 +454,7 @@ third-party OIDC JWT (Clerk, Supabase, Auth0, ...) with ```typescript import { OidcFederationStrategy } from "@cipherstash/stack" -import { encryptedSupabaseV3 } from "@cipherstash/stack-supabase" +import { encryptedSupabase } from "@cipherstash/stack-supabase" const strategy = OidcFederationStrategy.create( process.env.CS_WORKSPACE_CRN!, @@ -451,7 +462,7 @@ const strategy = OidcFederationStrategy.create( ) if (strategy.failure) throw new Error(strategy.failure.error.message) -const es = await encryptedSupabaseV3(supabaseUrl, supabaseKey, { +const es = await encryptedSupabase(supabaseUrl, supabaseKey, { config: { authStrategy: strategy.data }, }) ``` @@ -502,7 +513,7 @@ const { data, error } = await es ```typescript import { encryptedTable, types } from "@cipherstash/stack/eql/v3" -import { encryptedSupabaseV3 } from "@cipherstash/stack-supabase" +import { encryptedSupabase } from "@cipherstash/stack-supabase" // Optional declared schema — compile-time types. Introspection alone // (no `schemas`) also works. @@ -511,7 +522,7 @@ const users = encryptedTable("users", { amount: types.IntegerOrd("amount"), }) -const es = await encryptedSupabaseV3( +const es = await encryptedSupabase( process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!, { schemas: { users } }, // databaseUrl defaults to DATABASE_URL @@ -581,19 +592,25 @@ at all — only `.is(column, null)`. `@cipherstash/stack-supabase` also exports the following types: -- `EncryptedSupabaseV3Options`, `EncryptedSupabaseV3Instance`, `TypedEncryptedSupabaseV3Instance`, `EncryptedQueryBuilderV3`, `EncryptedQueryBuilderV3Untyped`, `V3Schemas` -- `SupabaseClientLike` -- `EncryptedSupabaseConfig`, `EncryptedSupabaseInstance`, `EncryptedQueryBuilder`, `PendingOrCondition` (legacy EQL v2) +- `EncryptedSupabaseOptions`, `EncryptedSupabaseInstance`, `TypedEncryptedSupabaseInstance`, `EncryptedQueryBuilder`, `EncryptedQueryBuilderUntyped`, `EncryptedQueryBuilderCore`, `V3Schemas` +- `FilterableKeys`, `FreeTextSearchableKeys` +- `EncryptedSupabaseResponse`, `EncryptedSupabaseError`, `PendingOrCondition`, `SupabaseClientLike` + +Each `*V3`-suffixed name from earlier releases (`EncryptedSupabaseV3Options`, +`EncryptedSupabaseV3Instance`, `TypedEncryptedSupabaseV3Instance`, +`EncryptedQueryBuilderV3`, `EncryptedQueryBuilderV3Untyped`, `V3FilterableKeys`, +`V3FreeTextSearchableKeys`) is still exported as a `@deprecated`, type-identical +alias of its unsuffixed counterpart above. ## Migrating an Existing Column to Encrypted The hard case: a Supabase table that already exists with live data in a plaintext column you want to encrypt. You can't just change the column type — that would drop the data. -CipherStash splits this into two named steps with a hard production-deploy gate between them: an **encryption rollout** (schema-add + dual-write code) and an **encryption cutover** (backfill + rename + drop). The `stash-encryption` skill is the canonical reference for the lifecycle; this section walks the Supabase-specific shape. +CipherStash splits this into two named steps with a hard production-deploy gate between them: an **encryption rollout** (schema-add + dual-write code) and an **encryption cutover** (backfill + switch reads to the encrypted column + drop — under EQL v3, which this section's schema uses, the switch is an application-side change with no rename; under legacy EQL v2 it is a rename). The `stash-encryption` skill is the canonical reference for the lifecycle; this section walks the Supabase-specific shape. -> **EQL version note.** The `stash encrypt *` tooling works with **both EQL versions** and auto-detects a column's version from its Postgres domain type — there is no flag. The lifecycles differ at the end: **v3** (the default, and what this section's schema uses) is `rollout → deploy gate → backfill → switch the app to the encrypted column by name → drop`, with **no cut-over rename**; **v2** finishes with `stash encrypt cutover` (a rename swap plus an `eql_v2_configuration` promotion) before the drop. Running `stash encrypt cutover` on a **backfilled** v3 column reports "not applicable" and exits 0 (it exits 1 if the backfill hasn't finished). +> **EQL version note.** The `stash encrypt *` tooling works with **both EQL versions** and detects a column's generation from its Postgres domain type — there is no flag. Detection is one-sided: a `public.eql_v3_*` domain classifies as **v3**; anything else — a plaintext column, or a legacy `eql_v2_encrypted` one — classifies as *unknown* and falls through to the **v2** lifecycle, which is the correct default for a v2 column. Only a v3 column has its version recorded in the migration manifest. The lifecycles differ at the end: **v3** (the default, and what this section's schema uses) is `rollout → deploy gate → backfill → switch the app to the encrypted column by name → drop`, with **no cut-over rename**; **v2** finishes with `stash encrypt cutover` (a rename swap plus an `eql_v2_configuration` promotion) before the drop. Running `stash encrypt cutover` on a **backfilled** v3 column reports "not applicable" and exits 0 (it exits 1 if the backfill hasn't finished). -> **Using CipherStash Proxy?** If you query encrypted data through [CipherStash Proxy](https://github.com/cipherstash/proxy) instead of the SDK, also run `stash db push` after schema-add and again before cutover to register the encrypted column shape with EQL. +> **Using CipherStash Proxy?** `stash db push` and `stash db activate` manage the `eql_v2_configuration` table, so they only apply to a database that has EQL v2 installed. EQL v3 ships no configuration table — on the v3-only database this section's schema assumes, `stash db push` reports "Nothing to do." and exits 0, and there is no cutover to run it before. If you query encrypted data through [CipherStash Proxy](https://github.com/cipherstash/proxy) against a legacy EQL v2 database instead of the SDK, run `stash db push` after schema-add to register the encrypted column shape with EQL. > **Runner note.** `stash init` adds `stash` to the project as a dev dependency, so `stash <command>` runs through whichever package manager the project uses (Bun, pnpm, Yarn, or npm) — examples below show this bare form. Before init has run, prefix with your package manager's one-shot runner: `bunx`, `pnpm dlx`, `yarn dlx`, or `npx`. The CLI's behaviour is identical across all of them. @@ -636,7 +653,7 @@ ALTER TABLE users Apply with `supabase db reset` locally or `supabase migration up` against the remote project. -No client-side schema change is required — `encryptedSupabaseV3` introspects +No client-side schema change is required — `encryptedSupabase` introspects the new column's domain at the next client startup. If you use declared `schemas`, add the column so it is typed: @@ -649,13 +666,13 @@ export const users = encryptedTable('users', { }) ``` -> **Using CipherStash Proxy?** Register the new encryption config with EQL: +> **Using CipherStash Proxy?** `stash db push` registers the encryption config in `eql_v2_configuration` — an EQL v2 + Proxy artifact. The `public.eql_v3_text_search` column added above needs nothing registered: EQL v3 keeps a column's config in its domain type and ships no configuration table, so on a v3-only database `db push` prints "Nothing to do." and exits 0. > > ```bash -> stash db push +> stash db push # EQL v2 + Proxy databases only > ``` > -> If this is the project's first encrypted column, `db push` writes directly to the active EQL config. If an active config already exists, it writes the new config as `pending` — that's expected. Cutover (later) will promote it. +> On a legacy EQL v2 database: if this is the project's first encrypted column, `db push` writes directly to the active EQL config. If an active config already exists, it writes the new config as `pending` — that's expected, and `stash db activate` promotes it to active. > > **SDK users:** Skip this step. Your encryption config lives in app code. @@ -665,7 +682,7 @@ Find **every** code path that writes to `users.email` and update it to also writ ```typescript // src/db/users.ts -import { es } from './clients' // encryptedSupabaseV3 instance +import { es } from './clients' // encryptedSupabase instance export async function insertUser(email: string) { return es.from('users').insert({ @@ -706,7 +723,7 @@ stash encrypt backfill --table users --column email # (CI: pass --confirm-dual-writes-deployed instead.) ``` -Resumable, idempotent, chunked. The CLI walks the table in keyset-pagination order, encrypts each chunk via the encryption client, and writes the ciphertext into `email_encrypted` inside transactions that also checkpoint to `cs_migrations`. SIGINT-safe. It auto-detects whether the column is EQL v2 or v3 and records that in `cs_migrations`. +Resumable, idempotent, chunked. The CLI walks the table in keyset-pagination order, encrypts each chunk via the encryption client, and writes the ciphertext into `email_encrypted` inside transactions that also checkpoint to `cs_migrations`. SIGINT-safe. It detects a `public.eql_v3_*` column as EQL v3 and records that in `cs_migrations`; a legacy `eql_v2_encrypted` column does not classify and takes the v2 lifecycle with no version recorded. If something goes wrong (e.g. you discover the dual-write code wasn't actually live when backfill ran), re-run with `--force` to re-encrypt every row regardless of current state. @@ -714,60 +731,22 @@ If something goes wrong (e.g. you discover the dual-write code wasn't actually l **EQL v3 (the schema above): there is no cut-over.** The encrypted column keeps its own name — point your application at `email_encrypted` through the -`encryptedSupabaseV3` wrapper, deploy, verify reads decrypt correctly, then skip +`encryptedSupabase` wrapper, deploy, verify reads decrypt correctly, then skip ahead to the drop step. Running `stash encrypt cutover` on a **backfilled** v3 column reports "not applicable" and exits 0 (it exits 1 if the backfill hasn't finished). -The rest of this subsection is the **EQL v2** path (an `eql_v2_encrypted` twin -queried through the legacy `encryptedSupabase` wrapper), kept for existing v2 -deployments. - -First, if you use declared `schemas`, update them to the post-cutover shape — the encrypted column will live under the original column name: - -```typescript -// src/encryption/schema.ts (post-cutover) -export const users = encryptedTable('users', { - email: types.TextSearch('email'), -}) -``` - -(Without declared schemas, introspection picks up the renamed column at the next client startup.) - -> **Known gap (EQL v2, SDK-only users):** `stash encrypt cutover` requires a pending EQL configuration, which is set by `stash db push`. If you're using the SDK without Proxy, you'll hit a "No pending EQL configuration" error from cutover. **Workaround:** run `stash db push` once before `stash encrypt cutover`. EQL v3 columns never hit this — cut-over doesn't apply to them. -> -> **Using CipherStash Proxy?** Re-push the encryption config so EQL has a pending row that points at `email` (no `_encrypted` suffix): -> -> ```bash -> stash db push -> # → writes the new config as `pending`. Active config (still pointing at -> # `email_encrypted`) keeps serving while we complete the cutover. -> ``` - -Now run the cutover: - -```bash -stash encrypt cutover --table users --column email -``` - -Inside one transaction it: (1) renames `email` → `email_plaintext` and `email_encrypted` → `email`, (2) promotes the pending EQL config to `active` (and the prior active to `inactive`), (3) records a `cut_over` event in `cs_migrations`. - -App code that does `select('email')` now returns ciphertext that must be decrypted via the `encryptedSupabaseV3` wrapper. **This is the moment that breaks read paths if they aren't going through the wrapper.** - -Update read paths to use the wrapper: - -```typescript -// Before -const { data } = await supabase.from('users').select('email').eq('id', id).single() - -// After — the wrapper decrypts transparently -const { data } = await es.from('users').select('email').eq('id', id).single() -``` - -For supported scalar queries that filter on `email`, the wrapper handles the -encrypted operators internally — calls such as `.eq()` and `.gte()` keep the -same shape, but values are encrypted before reaching the database. See -`## Query Filters` above for the EQL 3.0.2 PostgREST limitations. +> **EQL v2 cutover (`stash encrypt cutover`) via this SDK is no longer +> supported.** `@cipherstash/stack-supabase` authors and queries EQL v3 only — +> the v2 `encryptedSupabase({ encryptionClient, supabaseClient })` wrapper that +> read the post-cutover `eql_v2_encrypted` column has been removed. The CLI +> lifecycle commands still handle a v2 column — detection is one-sided, so a v2 +> column classifies as *unknown* and falls through to the v2 lifecycle — but the +> SDK read path for one is gone. If you have an in-flight v2 cutover, either pin +> the last `@cipherstash/stack-supabase` release that shipped the v2 wrapper to +> finish it, or (recommended) create an `eql_v3_*` twin and run the v3 rollout +> above. New +> encryption should always target an `eql_v3_*` domain. #### Drop: remove the plaintext column @@ -777,9 +756,9 @@ Once read paths are routing through the wrapper and you're confident reads are d stash encrypt drop --table users --column email ``` -The CLI emits a Supabase migration file with the drop. **Which column it drops depends on the EQL version**, which the CLI auto-detects: +The CLI emits a Supabase migration file with the drop. **Which column it drops depends on the EQL version.** Detection is one-sided: a `public.eql_v3_*` domain classifies as **v3**; anything else classifies as *unknown* and falls through to the **v2** path: -- **v3** — drops the original plaintext column, `ALTER TABLE users DROP COLUMN email;`. There was no rename, so no `email_plaintext` exists. Requires the `backfilled` phase plus a live coverage check. +- **v3** — drops the original plaintext column, `email`. There was no rename, so no `email_plaintext` exists. The SQL is not a bare `ALTER TABLE`: it's a `DO $stash_drop$` block that takes `LOCK TABLE users IN ACCESS EXCLUSIVE MODE`, re-counts rows where `email IS NOT NULL AND email_encrypted IS NULL` *at apply time*, `RAISE EXCEPTION`s if any remain, and only then executes the `ALTER TABLE ... DROP COLUMN` — so a row written after generation can't be silently destroyed. Requires the `backfilled` phase plus a live coverage check at generation time. - **v2** — drops the post-rename leftover, `ALTER TABLE users DROP COLUMN email_plaintext;`. Requires the `cut-over` phase. Review and apply with `supabase migration up` (or `supabase db reset` locally). Then remove the dual-write code from app paths — the plaintext column is gone; only the encrypted column is written now, through the wrapper. @@ -796,15 +775,39 @@ All three are read-only. ## Legacy: EQL v2 -Earlier versions of this integration stored ciphertext in `jsonb` / -composite `eql_v2_encrypted` columns (enabled via `CREATE EXTENSION eql_v2` -or the v2 EQL bundle) and queried them through the `encryptedSupabase({ -supabaseClient, encryptionClient })` factory, which takes a hand-written -client-side schema and a two-argument `from(tableName, schema)`. That surface -still ships in `@cipherstash/stack-supabase` and is unchanged — keep using it -for existing v2 deployments — but it is not the recommended path for new -projects: use `encryptedSupabaseV3`. The CLI rollout tooling (`stash encrypt -backfill` / `cutover` / `drop`) supports both generations and auto-detects which -one a column uses, so a v2 twin is no longer needed to get CLI-managed -backfill — see the EQL version note in the migration section above. For the v2 -wrapper's full API and semantics, see the docs at https://cipherstash.com/docs. +Earlier versions of this integration stored ciphertext in composite +`eql_v2_encrypted` columns (enabled via `CREATE EXTENSION eql_v2` or the v2 EQL +bundle) and both wrote and read them through the `encryptedSupabase({ +supabaseClient, encryptionClient })` factory — a hand-written client-side schema +and a two-argument `from(tableName, schema)`. + +**That v2 wrapper has been removed.** `@cipherstash/stack-supabase` now authors +and queries EQL v3 only, via the introspecting `encryptedSupabase(url, key)` / +`encryptedSupabase(client, options)` factory described above. There is no longer +a code path in this package that emits or reads `eql_v2_encrypted` columns. + +Passing a v2 table in `schemas` is rejected by name: + +``` +[supabase v3]: schemas entry "users" is an EQL v2 table — it has no +buildColumnKeyMap(), the marker every v3 table carries. +``` + +A v2 `encryptedTable` is structurally identical to a v3 one apart from that +marker, so TypeScript alone will not always catch the swap — re-author the table +with `encryptedTable`/`types` from `@cipherstash/stack/eql/v3`. + +Existing v2 deployments have two options: + +- **Migrate to EQL v3** (recommended): add an `eql_v3_*` twin column and run the + rollout in "Migrating an Existing Column to Encrypted" above. +- **Stay on v2 for now:** pin the last `@cipherstash/stack-supabase` release that + shipped the v2 wrapper. The CLI rollout tooling (`stash encrypt backfill` / + `cutover` / `drop`) still drives a v2 column, but only because detection is + one-sided: a v2 column is never detected as v2, it classifies as *unknown* and + falls through to the v2 lifecycle. No EQL version is recorded for it in + `.cipherstash/migrations.json`, so `stash encrypt status` reports no version + for that column. + +For the removed v2 wrapper's historical API and semantics, see the docs at +https://cipherstash.com/docs. diff --git a/turbo.json b/turbo.json index 4ef8a864b..0acfb8b38 100644 --- a/turbo.json +++ b/turbo.json @@ -3,6 +3,7 @@ "tasks": { "build": { "dependsOn": ["^build"], + "outputs": ["dist/**"], "inputs": ["$TURBO_DEFAULT$", ".env*"], "env": ["STASH_POSTHOG_KEY"] }, @@ -19,6 +20,45 @@ "inputs": ["$TURBO_DEFAULT$", ".env*"], "cache": false }, + // `packages/bench`'s "build" is `tsc --noEmit` — a typecheck, not a bundle — + // so it emits nothing and the repo-wide `dist/**` would warn on every run. + "@cipherstash/bench#build": { + "dependsOn": ["^build"], + "outputs": [] + }, + // These two builds `cpSync('../../skills', 'dist/skills')` — they consume a + // directory OUTSIDE their own package, which `$TURBO_DEFAULT$` does not + // cover. Without naming it here a skills-only edit does not invalidate the + // build, and since `build` now declares `outputs: ["dist/**"]`, the cache + // hit actively RESTORES the previous `dist/skills`. `skills/` ships inside + // the `stash` and `@cipherstash/wizard` tarballs and is copied into + // customer repos by `stash init`, so that silently publishes stale guidance. + "stash#build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/skills/**", ".env*"], + "env": ["STASH_POSTHOG_KEY"] + }, + "@cipherstash/wizard#build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/skills/**", ".env*"], + "env": ["STASH_POSTHOG_KEY"] + }, + // Typechecks `packages/stack/dist-types` against the BUILT declarations, so + // it must run after `build` — unlike `test:types`, which reads source. + "test:types:dist": { + "dependsOn": ["build"], + "inputs": ["$TURBO_DEFAULT$"] + }, + "typecheck": { + "dependsOn": ["^build"], + "inputs": ["$TURBO_DEFAULT$"] + }, + "typecheck:scaffold": { + "dependsOn": ["^build"], + "inputs": ["$TURBO_DEFAULT$"] + }, "test:e2e": { "dependsOn": ["^build", "build"], "inputs": ["$TURBO_DEFAULT$", ".env*"], diff --git a/vitest.shared.ts b/vitest.shared.ts index cbf78fd5b..3b2c3554b 100644 --- a/vitest.shared.ts +++ b/vitest.shared.ts @@ -33,6 +33,10 @@ export const sharedAlias: Record<string, string> = { repoRoot, 'packages/test-kit/src/catalog.ts', ), + '@cipherstash/test-kit/install': resolve( + repoRoot, + 'packages/test-kit/src/install.ts', + ), '@cipherstash/test-kit/integration-clerk': resolve( repoRoot, 'packages/test-kit/src/integration/clerk.ts', @@ -78,11 +82,8 @@ export const sharedAlias: Record<string, string> = { 'packages/stack-supabase/src/index.ts', ), // The Drizzle adapter package (was `@cipherstash/stack/drizzle` + - // `@cipherstash/stack/eql/v3/drizzle`). `/v3` first — longest prefix wins. - '@cipherstash/stack-drizzle/v3': resolve( - repoRoot, - 'packages/stack-drizzle/src/v3/index.ts', - ), + // `@cipherstash/stack/eql/v3/drizzle`). Root only: the `./v3` subpath + // collapsed into it, so there is no longer a longer prefix to order first. '@cipherstash/stack-drizzle': resolve( repoRoot, 'packages/stack-drizzle/src/index.ts',