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/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-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/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/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..8d17b9a54
--- /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 `eqlVersion: 2` selects `Encryption`'s nominal (untyped) overload at
+runtime — not the `TypedEncryptionClient` that `cipherstashFromStack` returns as
+`encryptionClient`. The field disagreed with the client you got back.
+
+**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/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..66ad0f209
--- /dev/null
+++ b/.changeset/rewriter-never-drops-ciphertext.md
@@ -0,0 +1,51 @@
+---
+'@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.
+
+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.
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-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..de4b34cdf
--- /dev/null
+++ b/.changeset/stack-audit-on-decrypt.md
@@ -0,0 +1,56 @@
+---
+'@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 non-empty tuple of tables and 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, which is
+the client you actually get back. Callers passing a plain `AnyV3Table[]` rather
+than an array literal must narrow it to `readonly [AnyV3Table, ...AnyV3Table[]]`.
+`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-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/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/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 86f52b389..9ce0dfe2e 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..7dd8310ba 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'
diff --git a/.github/workflows/integration-prisma-next.yml b/.github/workflows/integration-prisma-next.yml
index f3b128e01..acfb99b64 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'
diff --git a/.github/workflows/integration-supabase.yml b/.github/workflows/integration-supabase.yml
index 7ab83ca46..98b281655 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/**'
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 eb3691d1d..5b8da9735 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.4.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,16 @@ 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)
+ # 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 5b7ad22d5..849feafbf 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -136,21 +136,41 @@ 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
+
- name: Lint — no hardcoded package-manager runners
run: pnpm run lint:runners
+ # 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 +180,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 +365,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 +374,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..943dbb981 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -73,20 +73,13 @@ 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)
@@ -149,8 +142,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 +154,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 +218,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/e2e/package.json b/e2e/package.json
index 7fd7205eb..06cf30efb 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -9,7 +9,6 @@
},
"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/package.json b/package.json
index 75cbff39a..13cfd258b 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,6 +29,7 @@
"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:workflow-cache": "node scripts/lint-no-workflow-caching.mjs",
"release": "pnpm run build && changeset publish",
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/package.json b/packages/bench/package.json
index 1aa5b847a..4134c57ce 100644
--- a/packages/bench/package.json
+++ b/packages/bench/package.json
@@ -5,6 +5,7 @@
"description": "Performance / index-engagement benchmarks for stack integrations (Drizzle, encryptedSupabase, Prisma).",
"type": "module",
"scripts": {
+ "build": "tsc --noEmit",
"db:setup": "tsx src/cli/setup.ts",
"db:reset": "tsx src/cli/reset.ts",
"test:local": "vitest run",
@@ -17,6 +18,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..16ba56d2e 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 { 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,34 +8,23 @@ 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)
@@ -49,11 +34,26 @@ export type BenchPlaintextRow = {
enc_jsonb: { idx: number; group: number }
}
+/**
+ * Build the typed EQL v3 client this bench drives. Wrapped in a
+ * single-signature helper because `EncryptionV3` is now overloaded (typed v3
+ * vs. nominal) — `ReturnType` resolves to the *last*
+ * (nominal) overload, so we infer the return type through this helper instead.
+ */
+function makeEncryptionClient() {
+ return EncryptionV3({ schemas: [encryptionBenchTable] })
+}
+
+/** The typed EQL v3 client this bench drives. */
+export type BenchEncryptionClient = Awaited<
+ ReturnType
+>
+
export type BenchHandle = {
pgClient: pg.Client
pool: pg.Pool
db: ReturnType
- encryptionClient: EncryptionClient
+ encryptionClient: BenchEncryptionClient
}
/**
@@ -69,7 +69,7 @@ export async function buildBench(): Promise {
const db = drizzle(pool)
- const encryptionClient = await Encryption({ schemas: [encryptionBenchTable] })
+ const encryptionClient = await makeEncryptionClient()
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..e9fc828b6 100644
--- a/packages/bench/src/harness/seed.ts
+++ b/packages/bench/src/harness/seed.ts
@@ -53,11 +53,10 @@ 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}`,
@@ -65,12 +64,12 @@ export async function seed(
}
// bulkEncryptModels returns rows keyed by the encryptedTable column names
- // (snake_case here). Drizzle's `benchTable` uses camelCase TS field names —
- // remap before insert.
+ // (snake_case here) with encrypted EQL v3 envelopes as values. 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 },
+ encText: r.enc_text,
+ encInt: r.enc_int,
+ encJsonb: r.enc_jsonb,
}))
for (let i = 0; i < encRows.length; 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/cli/src/__tests__/rewrite-migrations.test.ts b/packages/cli/src/__tests__/rewrite-migrations.test.ts
index e213cc886..275ee5852 100644
--- a/packages/cli/src/__tests__/rewrite-migrations.test.ts
+++ b/packages/cli/src/__tests__/rewrite-migrations.test.ts
@@ -15,7 +15,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 +58,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 +76,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 +98,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 +114,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 +142,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 +195,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 +223,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 +271,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 +294,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 +311,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 +326,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 +341,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 +361,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 +387,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 +459,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 +616,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 +629,438 @@ 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)
+ })
+ })
+
+ // 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')
+ })
+
+ // 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')
+ })
+ })
+
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 +1077,257 @@ 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([])
+ })
+ })
})
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..cf3d0e0be 100644
--- a/packages/cli/src/commands/db/rewrite-migrations.ts
+++ b/packages/cli/src/commands/db/rewrite-migrations.ts
@@ -95,12 +95,429 @@ 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 are NOT tracked: a `--` or `'` inside one reads as a
+ * comment/literal here, which can only make us skip a rewrite (the statement
+ * then fails loudly at migrate time), never perform a destructive one.
+ */
+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 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, "'")
+ // 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 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.
+ */
+function endOfQuoted(sql: string, open: number, quote: "'" | '"'): number {
+ let i = open + 1
+ while (i < sql.length) {
+ 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.
+ */
+const ENCRYPTED_TYPE_REF = String.raw`(?:${MANGLED_TYPE_FORMS})(?=[\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, $3 the column-definition body. */
+const CREATE_TABLE_RE = new RegExp(
+ String.raw`CREATE TABLE\s+(?:IF NOT EXISTS\s+)?${TABLE_REF}\s*\(([\s\S]*?)\)\s*;`,
+ 'gi',
+)
+
+/** `"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 MANGLED_TYPE_FORMS} covering 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 forms do this: a
+ * domain installed into a non-`public` schema (`"email" "app"."eql_v3_text_search"`,
+ * since the mangled forms only special-case the literal `public` schema), and
+ * an array of the domain (`ADD COLUMN "email" public.eql_v3_text_search[]`,
+ * since {@link ENCRYPTED_TYPE_REF}'s trailing delimiter lookahead does not
+ * include `[`). Neither is a layout EQL installs into or a shape drizzle-kit
+ * emits, and both behaved identically before this branch — so this is a
+ * documentation gap, not a regression this branch introduced.
+ *
+ * **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}. */
+function columnKey(table: string, column: string, schema?: string): string {
+ return JSON.stringify([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_RE)) {
+ if (isInsideCommentOrString(sql, created.index)) continue
+ const { schema, table } = tableOf(created[1], created[2])
+ const body = created[3]
+
+ // 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 +550,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'))
+ }
- const original = 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()])
+
+ 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,11 +618,32 @@ 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.
@@ -189,7 +662,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__/encrypt-v3.test.ts b/packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts
index da2389aff..45def1ef2 100644
--- a/packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts
+++ b/packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts
@@ -430,10 +430,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/backfill.ts b/packages/cli/src/commands/encrypt/backfill.ts
index 7f7edb006..1698d8825 100644
--- a/packages/cli/src/commands/encrypt/backfill.ts
+++ b/packages/cli/src/commands/encrypt/backfill.ts
@@ -141,8 +141,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 +556,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/cutover.ts b/packages/cli/src/commands/encrypt/cutover.ts
index 6584ef075..676c4e244 100644
--- a/packages/cli/src/commands/encrypt/cutover.ts
+++ b/packages/cli/src/commands/encrypt/cutover.ts
@@ -78,8 +78,10 @@ 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,
diff --git a/packages/cli/src/commands/encrypt/drop.ts b/packages/cli/src/commands/encrypt/drop.ts
index 2fe440bb0..9beb6953a 100644
--- a/packages/cli/src/commands/encrypt/drop.ts
+++ b/packages/cli/src/commands/encrypt/drop.ts
@@ -84,8 +84,9 @@ 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,
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..1e5bc6cae
--- /dev/null
+++ b/packages/cli/src/commands/encrypt/lib/__tests__/resolve-eql.test.ts
@@ -0,0 +1,59 @@
+/**
+ * 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. The post-cutover v2 state (the
+ * ciphertext renamed onto the plaintext column's own name) therefore arrives
+ * here as an EMPTY candidate list, which the first guard already falls through
+ * on. 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.
+ */
+
+import type { EncryptedColumnInfo } from '@cipherstash/migrate'
+import { describe, expect, it } from 'vitest'
+import { explainUnresolved } from '../resolve-eql.js'
+
+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('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')
+ })
+})
diff --git a/packages/cli/src/commands/encrypt/lib/resolve-eql.ts b/packages/cli/src/commands/encrypt/lib/resolve-eql.ts
index 9ce9afb6a..b4e595a10 100644
--- a/packages/cli/src/commands/encrypt/lib/resolve-eql.ts
+++ b/packages/cli/src/commands/encrypt/lib/resolve-eql.ts
@@ -68,16 +68,20 @@ export async function resolveColumnLifecycle(
/**
* 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 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 now also covers the post-cutover v2 state — `` was
+ * renamed onto the ciphertext, and its `eql_v2_encrypted` domain is no longer
+ * classified, so the column never appears as a candidate.
*
- * 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,
@@ -85,9 +89,6 @@ export function explainUnresolved(
candidates: readonly EncryptedColumnInfo[],
): string | null {
if (candidates.length === 0) return null
- if (candidates.some((c) => c.column === column && c.version === 2)) {
- return null
- }
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..9f0fa1f90 100644
--- a/packages/cli/src/commands/eql/__tests__/migration.test.ts
+++ b/packages/cli/src/commands/eql/__tests__/migration.test.ts
@@ -217,6 +217,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 +247,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)
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__/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/setup-prompt.ts b/packages/cli/src/commands/init/lib/setup-prompt.ts
index ae3fa5aa3..dd5ba0de7 100644
--- a/packages/cli/src/commands/init/lib/setup-prompt.ts
+++ b/packages/cli/src/commands/init/lib/setup-prompt.ts
@@ -277,7 +277,7 @@ export function renderImplementPrompt(ctx: SetupPromptContext): string {
'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.',
'',
"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. Use the patterns in the integration skill — the `types.*` domain factories from `@cipherstash/stack-drizzle` for Drizzle, and the `types.*` factories from `@cipherstash/stack/eql/v3` (via `encryptedTable`, passed as `schemas`) for Supabase. Encrypted columns must be **nullable `jsonb`** at creation time. 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).',
@@ -303,7 +303,7 @@ 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 → \`\`).`,
+ `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 → \`\`). The adapters no longer author v2 — \`@cipherstash/stack-drizzle\` removed \`encryptedType\` — so declare the column with a \`types.*\` v3 domain and reach v2 rows through \`@cipherstash/stack\`'s decrypt path.`,
'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.',
'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.`,
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..fc0e6aec1 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,7 +381,7 @@ 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
@@ -389,7 +389,7 @@ const DRIZZLE_PLACEHOLDER = `/**
* pointing at 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 +404,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 +419,19 @@ 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 } from '@cipherstash/stack/v3'
-export const encryptionClient = await EncryptionV3({ schemas: [] })
+export const encryptionClient = await Encryption({ schemas: [] })
`
const GENERIC_PLACEHOLDER = `/**
@@ -440,7 +440,7 @@ 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
@@ -474,16 +474,16 @@ 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 } from '@cipherstash/stack/v3'
-export const encryptionClient = await EncryptionV3({ schemas: [] })
+export const encryptionClient = await Encryption({ schemas: [] })
`
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/src/__tests__/version.test.ts b/packages/migrate/src/__tests__/version.test.ts
index b6cfdb9d6..96e5cfbb6 100644
--- a/packages/migrate/src/__tests__/version.test.ts
+++ b/packages/migrate/src/__tests__/version.test.ts
@@ -18,8 +18,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 +51,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 +103,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 +169,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()
})
diff --git a/packages/migrate/src/version.ts b/packages/migrate/src/version.ts
index dccfe7e35..60e1d9d41 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
diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json
index c318db4c1..2d7deebad 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",
diff --git a/packages/prisma-next/src/stack/from-stack-v3.ts b/packages/prisma-next/src/stack/from-stack-v3.ts
index 28ab70452..2f569b550 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 returns the nominal (untyped) client at
+ * runtime, which is not what this entry point hands back.
+ */
+ readonly encryptionConfig?: V3ClientConfig
}
export interface CipherstashFromStackV3Result {
@@ -83,8 +89,11 @@ export async function cipherstashFromStack(
)
}
- const derived = deriveStackSchemasV3(opts.contractJson)
- if (derived.length === 0) {
+ // Destructured rather than length-checked so the non-emptiness survives into
+ // the type layer: `Encryption` requires a non-empty schema tuple (an empty one
+ // used to type-check and then throw at runtime).
+ const [firstDerived, ...restDerived] = deriveStackSchemasV3(opts.contractJson)
+ if (firstDerived === undefined) {
throw new Error(
'cipherstashFromStack: no v3 cipherstash columns found in contract.json. ' +
'Declare at least one v3 `cipherstash.*()` column (e.g. `cipherstash.TextSearch()`) in prisma/schema.prisma ' +
@@ -92,7 +101,10 @@ export async function cipherstashFromStack(
)
}
- const schemas = resolveV3Schemas(derived, opts.schemasV3)
+ const schemas = resolveV3Schemas(
+ [firstDerived, ...restDerived],
+ opts.schemasV3,
+ )
const encryptionClient = await EncryptionV3({
schemas,
@@ -132,15 +144,18 @@ function collectNonV3CipherstashCodecIds(
return [...ids].sort()
}
+/** A schema list carrying its non-emptiness in the type, as `Encryption` wants. */
+type NonEmptyV3Schemas = readonly [AnyV3Table, ...AnyV3Table[]]
+
/**
* Validate contract-declared tables against their overrides (exact
* domain identity) and append override-only tables — same merge
* semantics as the v2 `resolveSchemas`.
*/
function resolveV3Schemas(
- derived: ReadonlyArray,
+ derived: NonEmptyV3Schemas,
override: ReadonlyArray | undefined,
-): ReadonlyArray {
+): NonEmptyV3Schemas {
if (override === undefined || override.length === 0) return derived
const derivedByName = new Map(derived.map((t) => [t.tableName, t]))
@@ -153,7 +168,8 @@ function resolveV3Schemas(
}
return [
- ...derived,
+ derived[0],
+ ...derived.slice(1),
...override.filter((t) => !derivedByName.has(t.tableName)),
]
}
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.
-
-[](https://cipherstash.com)
-[](https://www.npmjs.com/package/@cipherstash/protect-dynamodb)
-[](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 @@
-
-
-
- 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)
-
-
-
-## 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