diff --git a/.changeset/adapter-package-split.md b/.changeset/adapter-package-split.md index 13d871f4c..e5cb460f7 100644 --- a/.changeset/adapter-package-split.md +++ b/.changeset/adapter-package-split.md @@ -9,18 +9,17 @@ Split the Drizzle and Supabase integrations into their own packages. The adapters now ship as first-party packages that depend on `@cipherstash/stack`, following the `@cipherstash/prisma-next` precedent: -- **`@cipherstash/stack-drizzle`** — Drizzle ORM integration. EQL v2 on the package - root (`@cipherstash/stack-drizzle`: `encryptedType`, `extractEncryptionSchema`, - `createEncryptionOperators`) and EQL v3 on `@cipherstash/stack-drizzle/v3` - (`types` factories, `createEncryptionOperatorsV3`, `extractEncryptionSchemaV3`, …). -- **`@cipherstash/stack-supabase`** — Supabase integration: `encryptedSupabase` (v2) - and `encryptedSupabaseV3` (v3, connect-time introspection). +- **`@cipherstash/stack-drizzle`** — EQL v3 Drizzle integration on the package + root (`types` factories, `createEncryptionOperators`, + `extractEncryptionSchema`, …). +- **`@cipherstash/stack-supabase`** — EQL v3 Supabase integration through the + connect-time-introspecting `encryptedSupabase` factory. **Breaking (`@cipherstash/stack`):** the `./drizzle`, `./supabase`, and `./eql/v3/drizzle` subpath exports are removed. Migrate imports: - `@cipherstash/stack/drizzle` → `@cipherstash/stack-drizzle` -- `@cipherstash/stack/eql/v3/drizzle` → `@cipherstash/stack-drizzle/v3` +- `@cipherstash/stack/eql/v3/drizzle` → `@cipherstash/stack-drizzle` - `@cipherstash/stack/supabase` → `@cipherstash/stack-supabase` Add the relevant package to your dependencies alongside `@cipherstash/stack`. A new diff --git a/.changeset/decrypt-lock-context-binds-once.md b/.changeset/decrypt-lock-context-binds-once.md new file mode 100644 index 000000000..51a169112 --- /dev/null +++ b/.changeset/decrypt-lock-context-binds-once.md @@ -0,0 +1,25 @@ +--- +'@cipherstash/stack': patch +--- + +`decryptModel` / `bulkDecryptModels` now drop `.withLockContext()` from the +returned operation once a lock context is bound, so binding twice is a compile +error instead of a runtime throw. + +Passing a lock context positionally and then chaining it — +`client.decryptModel(row, users, lockContext).withLockContext(lockContext)` — +type-checked, then threw `this decrypt operation is already bound to a lock +context`. The type promised a method the runtime rejected. + +The operation interface is split to match what the encrypt path already does, +where `EncryptModelOperationWithLockContext` simply lacks the method: the +new `LockBoundDecryptModelOperation` carries `.audit()` only, and +`AuditableDecryptModelOperation.withLockContext()` returns it. The runtime throw +stays as the backstop for plain-JavaScript callers. + +An OPTIONAL lock context still type-checks. `decryptModel(row, users, +session?.lockContext)` — where the value is `LockContextInput | undefined` — is +the ordinary shape for code that decrypts identity-bound rows only for +signed-in users, and it compiled against the single optional parameter this +replaces. The positional overloads accept `LockContextInput | undefined` so it +keeps compiling; `undefined` binds nothing. diff --git a/.changeset/drizzle-kit-eql-v3-ddl.md b/.changeset/drizzle-kit-eql-v3-ddl.md index 32a295e6c..b9cfab05b 100644 --- a/.changeset/drizzle-kit-eql-v3-ddl.md +++ b/.changeset/drizzle-kit-eql-v3-ddl.md @@ -13,9 +13,9 @@ hand-repaired. The v3 column now emits the **unqualified** domain (`eql_v3_text_search`), which drizzle-kit renders as the valid `"eql_v3_text_search"` and which resolves via the -search path (the domains live in `public`). This matches how the v2 -`encryptedType` surface already declares its type, and how drizzle-kit reads the -type back during a `push` introspection diff, so the two sides no longer disagree. +search path (the domains live in `public`). This also matches how drizzle-kit +reads the type back during a `push` introspection diff, so the two sides no +longer disagree. Builder recovery still yields the canonical `public.eql_v3_*` identity, so operators and schema extraction are unchanged. diff --git a/.changeset/dynamodb-eql-v3.md b/.changeset/dynamodb-eql-v3.md index 8ba065f9d..26eabab2c 100644 --- a/.changeset/dynamodb-eql-v3.md +++ b/.changeset/dynamodb-eql-v3.md @@ -10,13 +10,9 @@ Pass a table built with `encryptedTable` + the `types.*` domains from `decryptModel`, or `bulkDecryptModels`. Build the typed client with `Encryption({ schemas: [table] })`. -EQL v2 tables continue to be **readable** — `decryptModel` / -`bulkDecryptModels` still accept one, so existing items stay accessible. Writing -through a v2 table is a separate matter: `encryptModel` / `bulkEncryptModels` -narrowed to EQL v3 tables in this same release, so a caller that still encrypts -through a v2 table does need to change. The table decides which wire format is -used, so a DynamoDB table populated under one version must keep being read with -that version. +Existing EQL v2 items continue to be **readable**: pass the corresponding EQL v3 +table plus `{ storedEqlVersion: 2 }` to `decryptModel` / +`bulkDecryptModels`. Writes accept EQL v3 tables only. This fixes a latent bug that made v3 unusable: the write path detected an encrypted value by its `k: 'ct'` tag, but EQL v3 scalars carry no `k` @@ -73,4 +69,5 @@ type, where a declared column `email` becomes `email__source` (plus `email`. `decryptModel` / `bulkDecryptModels` invert it via `DecryptedAttributes`. `AnyEncryptedTable`, `DynamoDBEncryptionClient` and `AuditConfig` are now exported from `@cipherstash/stack/dynamodb` so these signatures can be named. -The EQL v2 **decrypt** overloads are unchanged; the v2 encrypt overloads are removed in this release. +Legacy reads use the explicit storage-version option rather than an EQL v2 table +overload; the v2 encrypt overloads are removed in this release. diff --git a/.changeset/dynamodb-skill-wasm-caveat.md b/.changeset/dynamodb-skill-wasm-caveat.md index 21d3f80ba..47807d694 100644 --- a/.changeset/dynamodb-skill-wasm-caveat.md +++ b/.changeset/dynamodb-skill-wasm-caveat.md @@ -2,13 +2,12 @@ 'stash': patch --- -The `stash-dynamodb` and `stash-encryption` skills documented EQL v2 decrypt as -unconditionally supported, without noting that `@cipherstash/stack/wasm-inline` -is EQL v3 only. Since that is the documented entry for Deno, Bun, Cloudflare -Workers and Supabase Edge Functions — runtimes commonly paired with DynamoDB — a -reader following the skill hit a runtime refusal with no forewarning. Both skills -now state that legacy v2 items are readable on the native `@cipherstash/stack` -entry only. +The `stash-dynamodb` and `stash-encryption` skills now state which entries serve +a legacy EQL v2 DynamoDB read: both of them. Schema authoring is EQL v3-only +everywhere, but the read is not — it reconstructs the v2 envelope around the +current v3 table, and `decrypt` accepts either wire generation. Deno, Bun, +Cloudflare Workers and Supabase Edge Functions can therefore read pre-migration +items through `@cipherstash/stack/wasm-inline`. The `stash-dynamodb` API reference also claimed audit metadata forwards to ZeroKMS "regardless of client shape". It does not: the wasm-inline client's diff --git a/.changeset/dynamodb-v2-grouped-field-reads.md b/.changeset/dynamodb-v2-grouped-field-reads.md new file mode 100644 index 000000000..d3c53248f --- /dev/null +++ b/.changeset/dynamodb-v2-grouped-field-reads.md @@ -0,0 +1,25 @@ +--- +'@cipherstash/stack': patch +--- + +Fix reading a nested EQL v2 DynamoDB attribute that was stored under a grouped +field. + +A v2 grouped column registered its build key on the BARE LEAF, so a field inside +a group was written as `.__source` while the schema knew it only as +``. The v3 rewrite made attribute matching exact-dotted-path only, for both +generations, which orphaned every such attribute: it read back as raw base64 +inside a `{ data }` success, with only a debug-level log — invisible at the +default log level. Silent wrong data on the one path that exists for backward +compatibility. + +The bare-leaf fallback is restored for `{ storedEqlVersion: 2 }` reads only. +It stays off for v3, where full dotted paths are registered precisely so a +nested leaf cannot collide with a same-named top-level column — matching by +bare leaf there rewrote a plaintext sibling as an envelope and handed it to the +FFI as a decrypt target. Writes are EQL v3-only and stay strict. + +If you carried a v2 grouped column forward as a top-level v3 column, you can +also declare it by its dotted path with the original DB name — +`'details.amount': types.TextEq('amount')` — which reproduces the v2 identifier +exactly. diff --git a/.changeset/dynamodb-v2-read-table-forwarding.md b/.changeset/dynamodb-v2-read-table-forwarding.md index 82918224a..06ee12fea 100644 --- a/.changeset/dynamodb-v2-read-table-forwarding.md +++ b/.changeset/dynamodb-v2-read-table-forwarding.md @@ -19,8 +19,10 @@ 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. +The current v3 table is now forwarded on every read, legacy storage included: +the legacy path reconstructs the v2 envelope around it, and the reconstructor +map is keyed by the current schema either way. (Schema authoring is EQL v3-only, +so there is no longer any such thing as a v2 table object to pass.) 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 diff --git a/.changeset/dynamodb-wasm-v2-read.md b/.changeset/dynamodb-wasm-v2-read.md index b75673051..993f1f3d4 100644 --- a/.changeset/dynamodb-wasm-v2-read.md +++ b/.changeset/dynamodb-wasm-v2-read.md @@ -2,24 +2,18 @@ '@cipherstash/stack': patch --- -`encryptedDynamoDB` now refuses a `@cipherstash/stack/wasm-inline` client paired -with a legacy EQL v2 table, instead of failing at the first read with a -misleading error. +`encryptedDynamoDB` now serves legacy `{ storedEqlVersion: 2 }` reads on the +`@cipherstash/stack/wasm-inline` entry, not just the native one. -The adapter's v2 read path deliberately calls `decryptModel(item)` with **no** -table — a v2 table means nothing to a v3 client's reconstructor map, and the -native clients derive the table from the payloads anyway. `WasmEncryptionClient` -cannot do that: its decrypt requires the table and resolves date fields from a -per-table map, so the omitted argument surfaced as -`TypeError: Cannot read properties of undefined (reading 'tableName')` thrown -from deep inside the client — on the documented entry for Deno, Cloudflare -Workers and Supabase Edge Functions, which satisfies the adapter's client type -structurally and so was accepted with no cast. +The legacy read reconstructs the EQL v2 envelope around the **current v3 table** +and forwards that table exactly as a v3 read does, so both entries can serve it: +protect-ffi's `decrypt` accepts either wire generation regardless of the +client's `eqlVersion`, and the reconstructor map is keyed by the current schema +either way. Deno, Cloudflare Workers and Supabase Edge Functions can therefore +read rows written before the v3 migration; previously the pairing was refused +outright and those runtimes had no way to read that data at all. -The pairing is now rejected at the call site, with a message naming both the -combination and the fix. The message is operation-neutral: the guard runs on all -four operations, so a plain-JS caller reaching the write path with a v2 table -gets a message that does not claim a read it never attempted. +Writes remain EQL v3-only on both entries. `encryptModel` / `bulkEncryptModels` now also tolerate a client whose encrypt returns a plain promise. They chained `.audit()` onto the result diff --git a/.changeset/encryption-schema-arrays.md b/.changeset/encryption-schema-arrays.md index f5a125303..c3cbb5f39 100644 --- a/.changeset/encryption-schema-arrays.md +++ b/.changeset/encryption-schema-arrays.md @@ -27,10 +27,16 @@ an array literal still gets full per-column typing — passing the wrong plainte for a column's domain, or a table the client was not built with, is still rejected. -`EncryptionClientFor` is widened to match, so it names the typed client for a -loose `readonly AnyV3Table[]` as well as for a tuple. Code that is generic over -its schemas — an adapter that builds a table per request, say — can now write -`EncryptionClientFor` and keep the typed surface. +`EncryptionClient` accepts the same schema parameter, so it names the client +for a loose `readonly AnyV3Table[]` as well as for a tuple. Code that is generic +over its schemas — an adapter that builds a table per request, say — can write +`EncryptionClient`. + +That keeps the `table` and `column` arguments checked, but not the model input: +with the schema parameter loose there is no per-column plaintext to resolve, so +`encryptModel` / `bulkEncryptModels` still reject an untyped +`Record` model and an adapter holding untyped rows needs a cast +at that one boundary. Full model typing requires a concrete schema tuple. If you narrowed a schema array to `readonly [AnyV3Table, ...AnyV3Table[]]` to satisfy the old signature, that narrowing is no longer needed. @@ -48,5 +54,5 @@ async function makeClient( Note the non-empty tuple constraint. A wrapper generic over a loose `readonly AnyV3Table[]` is still rejected — that type admits `readonly []`, so the wrapper cannot promise what `Encryption` requires. Constrain it as above, or -take `EncryptionClientFor` as a parameter instead of +take `EncryptionClient` as a parameter instead of building the client inside the generic function. diff --git a/.changeset/eql-v3-drizzle-encrypt-query.md b/.changeset/eql-v3-drizzle-encrypt-query.md index a0874de90..9fb894b91 100644 --- a/.changeset/eql-v3-drizzle-encrypt-query.md +++ b/.changeset/eql-v3-drizzle-encrypt-query.md @@ -16,7 +16,6 @@ operator overloads. This unifies the scalar/text operators with the JSON containment path (already on `encryptQuery`) and removes the previously-optional `encryptQuery` guard: it is now a required capability of the operand client. -`@cipherstash/stack` gains a batch `encryptQuery(terms)` overload on -`TypedEncryptionClient` (the type `EncryptionV3` returns), mirroring the nominal -`EncryptionClient`. This is additive — it lets `inArray`/`notInArray` encrypt a -whole list of query terms in one crossing. +`@cipherstash/stack` gains a batch `encryptQuery(terms)` overload on the generic +`EncryptionClient` returned by `Encryption`. This is additive — it lets +`inArray`/`notInArray` encrypt a whole list of query terms in one crossing. diff --git a/.changeset/eql-v3-drizzle.md b/.changeset/eql-v3-drizzle.md index aa45af42a..0ec9d099a 100644 --- a/.changeset/eql-v3-drizzle.md +++ b/.changeset/eql-v3-drizzle.md @@ -8,9 +8,9 @@ encrypted columns whose Postgres type is the semantic `public.`; the con type drives the legal query operators. `createEncryptionOperatorsV3` provides capability-checked `eq`/`ne`/`gt`/`gte`/`lt`/`lte`/`between`/`contains`/`inArray`/ `asc`/`desc`/`and`/`or` that emit the latest two-argument `eql_v3` SQL functions with -full-envelope operands, and -`extractEncryptionSchemaV3` rebuilds the schema for `EncryptionV3`. The existing v2 -`@cipherstash/stack/drizzle` integration is unchanged. +full-envelope operands, and `extractEncryptionSchemaV3` rebuilds the schema for +`Encryption`. This surface was subsequently consolidated at the +`@cipherstash/stack-drizzle` package root. The v3 text-search helper is `contains`; obsolete `like`/`ilike` helpers are not exposed because v3 free-text search is token containment rather than SQL wildcard diff --git a/.changeset/eql-v3-ga-rebaseline.md b/.changeset/eql-v3-ga-rebaseline.md index f0b8fdbee..b394d1741 100644 --- a/.changeset/eql-v3-ga-rebaseline.md +++ b/.changeset/eql-v3-ga-rebaseline.md @@ -9,15 +9,14 @@ Re-baseline EQL v3 on the eql-3.0.0 GA release and protect-ffi 0.29. (`public.eql_v3_text_search`, `public.eql_v3_integer_ord`, …) instead of the alpha-era bare names. Databases installed from an alpha bundle must be re-installed (`stash eql install --eql-version 3` replaces the schema). -- `encryptQuery` under `eqlVersion: 3` now returns EQL v3 query operands +- `encryptQuery` on the EQL v3 client now returns EQL v3 query operands (protect-ffi 0.29): term-only scalar operands for the `eql_v3.query_` domains, the `eql_v3.query_jsonb` containment needle, and bare selector hashes for JSON path queries — v3 scalar and selector queries no longer throw `EQL_V3_QUERY_UNSUPPORTED` (the code is gone). -- v2 `searchableJson()` columns now pin the SteVec encoding to `standard` - explicitly. protect-ffi 0.29 flipped the library default to `compat` - (EQL v3's encoding); without the pin, v2 JSON containment queries would - silently match nothing and newly written rows would not be comparable with - existing ones. +- Legacy EQL v2 JSON compatibility fixtures pin the SteVec encoding to + `standard` explicitly. protect-ffi 0.29 flipped the library default to + `compat` (EQL v3's encoding); without the pin, v2 JSON containment fixtures + would silently mismatch existing data. - The EQL v3 test/install SQL is sourced from the pinned `@cipherstash/eql` package (3.0.0) instead of a hand-vendored fixture. diff --git a/.changeset/eql-v3-prisma-next.md b/.changeset/eql-v3-prisma-next.md index 3d29dc32f..40d4564c0 100644 --- a/.changeset/eql-v3-prisma-next.md +++ b/.changeset/eql-v3-prisma-next.md @@ -11,9 +11,8 @@ - `EncryptedJson` — searchable encrypted JSONB (`public.eql_v3_json`, `ste_vec`), queried with `eqlJsonContains` (`@>` containment). Selector querying (comparing the value at a JSONPath) is tracked in #677. - **Impossible capability combinations have no constructor** (e.g. text equality + free-text without order/range) — they are unrepresentable, not runtime errors. - **BigInt is a first-class v3 family** (`EncryptedBigInt` / `EncryptedBigIntEq` / `EncryptedBigIntOrd`, JS `bigint` plaintext, backed by `public.eql_v3_bigint*`). -- Use the `*V2` constructors (`EncryptedStringV2`, `EncryptedDoubleV2`, `EncryptedBigIntV2`, `EncryptedDateV2`, `EncryptedBooleanV2`, `EncryptedJsonV2`) to keep EQL v2 columns. A client is v2 or v3 — the two runtime descriptors are never co-registered. -- New `@cipherstash/prisma-next/v3` entry point: `cipherstashFromStackV3({ contractJson })` builds the v3 runtime descriptor, bulk-encrypt middleware, and a stack `EncryptionV3` client from the emitted contract. -- Query operators use an **EQL-derived vocabulary** (`eqlEq`, `eqlNeq`, `eqlIn`, `eqlNotIn`, `eqlGt`, `eqlGte`, `eqlLt`, `eqlLte`, `eqlBetween`, `eqlNotBetween`, `eqlJsonContains`; ordering via `eqlAsc` / `eqlDesc`), lowering to the same-named `eql_v3.*` functions with operands cast to the domain's query type (`$n::eql_v3.query_`); ordering uses `eql_v3.ord_term` / `eql_v3.ord_term_ore` by the column's ordering flavour. The domains are `public.eql_v3_*`; the operator functions live in the `eql_v3` schema. (The v2 surface keeps its `cipherstash*` names.) +- New `@cipherstash/prisma-next/v3` entry point: `cipherstashFromStackV3({ contractJson })` builds the v3 runtime descriptor, bulk-encrypt middleware, and a stack `Encryption` client from the emitted contract. +- Query operators use an **EQL-derived vocabulary** (`eqlEq`, `eqlNeq`, `eqlIn`, `eqlNotIn`, `eqlGt`, `eqlGte`, `eqlLt`, `eqlLte`, `eqlBetween`, `eqlNotBetween`, `eqlJsonContains`; ordering via `eqlAsc` / `eqlDesc`), lowering to the same-named `eql_v3.*` functions with operands cast to the domain's query type (`$n::eql_v3.query_`); ordering uses `eql_v3.ord_term` / `eql_v3.ord_term_ore` by the column's ordering flavour. The domains are `public.eql_v3_*`; the operator functions live in the `eql_v3` schema. - Free-text search is **`eqlMatch`** — fuzzy bloom token matching (`eql_v3.contains`), deliberately NOT named after SQL `ILIKE`: matching is case-insensitive, order/multiplicity-insensitive, and one-sided (may false-positive). Two guards run before encryption: SQL wildcards are normalised (leading/trailing `%` stripped; interior `%` or any `_` rejected), and needles the column's match index cannot answer (empty / below the tokenizer length) are rejected via the shared `matchNeedleError` guard. There is **no negated match operator** — negating a may-false-positive bloom test would silently drop matching rows. - A new baseline migration `20260601T0100_install_eql_v3_bundle` (invariant `cipherstash:install-eql-v3-bundle-v1`) installs the `public.eql_v3_*` domains and `eql_v3.*` functions from the pinned `@cipherstash/eql` release. Regenerate contracts and run migrations after changing constructors. - **The v3 ORM surface is fully wired end-to-end** (proven by converting `examples/prisma`): diff --git a/.changeset/eql-v3-sole-docs.md b/.changeset/eql-v3-sole-docs.md index cb21d891e..baa9edf10 100644 --- a/.changeset/eql-v3-sole-docs.md +++ b/.changeset/eql-v3-sole-docs.md @@ -7,13 +7,13 @@ 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 (`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: +EQL v2 shrinks to read-compatibility notes. Two places keep more detail because +stored EQL v2 data 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). + / `bulkDecryptModels` can read previously stored v2 items when passed the + corresponding v3 table and `{ storedEqlVersion: 2 }`, so the + `stash-dynamodb` skill documents that explicit compatibility 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 diff --git a/.changeset/eql-v3-supabase-adapter.md b/.changeset/eql-v3-supabase-adapter.md index f690a896a..418d410f0 100644 --- a/.changeset/eql-v3-supabase-adapter.md +++ b/.changeset/eql-v3-supabase-adapter.md @@ -63,5 +63,6 @@ index now emits `include_original: false` — the flag is inert in protect-ffi ( bloom is trigram-only either way), so this moves no ciphertext and only pins the value a substring-search domain wants. -v2 (`encryptedSupabase`) is unchanged: it keeps `like`/`ilike` (`eql_v2.like`, -`~~`) and its raw-`filter` query-type mapping, so no v2 ciphertext moves. +This surface was subsequently consolidated as the unsuffixed +`encryptedSupabase` EQL v3 factory. The legacy v2 Supabase authoring wrapper has +been removed; core native decrypt remains compatible with stored v2 payloads. diff --git a/.changeset/eql-v3-typed-client.md b/.changeset/eql-v3-typed-client.md index 884b2ad8d..5e3ce9471 100644 --- a/.changeset/eql-v3-typed-client.md +++ b/.changeset/eql-v3-typed-client.md @@ -2,10 +2,11 @@ "@cipherstash/stack": minor --- -Add a strongly-typed EQL v3 client surface on a new `@cipherstash/stack/v3` -subpath (`EncryptionV3`, `typedClient`, `TypedEncryptionClient`). It re-exports -the v3 `types` namespace and table API (from `@cipherstash/stack/eql/v3`), so a -single import provides everything needed to author and use a v3 schema. +Add a strongly typed EQL v3 client surface on `@cipherstash/stack/v3`. +`Encryption` returns the generic `EncryptionClient` type and the subpath +re-exports the v3 `types` namespace and table API (from +`@cipherstash/stack/eql/v3`), so one import provides everything needed to author +and use a v3 schema. Every method derives its types from the concrete `table` / `column` builder arguments: @@ -24,6 +25,6 @@ Because the typed methods bind to the concrete branded v3 classes, a hand-rolled structural table/column is rejected — closing the soundness gap where a non-branded table could be encrypted at runtime while typed as plaintext. -Runtime behaviour is unchanged: the encrypt/query paths return the same operations -as the base client; only the model-decrypt paths add a per-column `Date` -reconstruction step. The v2 client surface (`Encryption`) is untouched. +The model-decrypt paths add per-column `Date` reconstruction. New clients author +EQL v3 only; native decrypt operations remain compatible with stored EQL v2 +payloads. diff --git a/.changeset/eql-v3-wasm-inline.md b/.changeset/eql-v3-wasm-inline.md index 6c30ba376..c67e659cc 100644 --- a/.changeset/eql-v3-wasm-inline.md +++ b/.changeset/eql-v3-wasm-inline.md @@ -23,8 +23,7 @@ EQL v3 exclusively: const client = await Encryption({ schemas: [patients], config }) ``` -The v2 schema builders (`encryptedColumn` / `encryptedField` / the v2 -`encryptedTable`) are no longer exported from this entry, and passing a v2 table -throws a clear error. The WASM path was never announced or documented for v2 and -had no known users; EQL v2 remains fully supported on the native -`@cipherstash/stack` entry. +Only the EQL v3 schema authoring surface is public, and passing a legacy table +shape throws a clear error. The WASM path was never announced or documented for +v2 and had no known users. The native `@cipherstash/stack` entry continues to +decrypt stored EQL v2 payloads, but new clients author EQL v3 only. diff --git a/.changeset/init-drizzle-eql-v3.md b/.changeset/init-drizzle-eql-v3.md index 4ae941236..5e54b75e2 100644 --- a/.changeset/init-drizzle-eql-v3.md +++ b/.changeset/init-drizzle-eql-v3.md @@ -9,8 +9,8 @@ The Drizzle init flow pinned `--eql-version 2`, because `stash eql install v2-only. That made `stash init --drizzle` the single flow that provisioned a v2 database — a bare `stash eql install`, and init for every other integration, already defaulted to v3. It also contradicted the `stash-drizzle` skill init -copies into the same project, which documents the v3 `@cipherstash/stack-drizzle/v3` -surface (`types.*` domains, `EncryptionV3`) and would have the user's agent +copies into the same project, which documents the v3 `@cipherstash/stack-drizzle` +surface (`types.*` domains, `Encryption`) and would have the user's agent author v3 code against a v2 database. Init's Drizzle flow now routes through `stash eql migration --drizzle`, so it diff --git a/.changeset/init-placeholder-eql-v3.md b/.changeset/init-placeholder-eql-v3.md index 6be0fe42e..6c75a89c1 100644 --- a/.changeset/init-placeholder-eql-v3.md +++ b/.changeset/init-placeholder-eql-v3.md @@ -6,18 +6,16 @@ database it installs. The placeholder client (`DRIZZLE_PLACEHOLDER` / `GENERIC_PLACEHOLDER`) and the -introspection-driven client generator previously emitted EQL v2 authoring -patterns — `Encryption({ schemas })`, `encryptedColumn(...).equality().freeTextSearch()`, -and `encryptedType('x', { equality: true })`. Since init installs a v3 -database, this handed the customer's coding agent v2 guidance against a v3 -schema (follow-up to #732 / #705). +introspection-driven client generator previously emitted legacy EQL v2 +authoring patterns. Since init installs a v3 database, this handed the +customer's coding agent v2 guidance against a v3 schema (follow-up to #732 / +#705). -Scaffolds now teach the v3 surface: `EncryptionV3` from `@cipherstash/stack/v3`, +Scaffolds now teach the v3 surface: `Encryption` from `@cipherstash/stack/v3`, 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. +The scaffolds emit `extractEncryptionSchema` from the collapsed +`@cipherstash/stack-drizzle` root. diff --git a/.changeset/init-scaffold-compiles.md b/.changeset/init-scaffold-compiles.md index acb2a14ff..3de825aea 100644 --- a/.changeset/init-scaffold-compiles.md +++ b/.changeset/init-scaffold-compiles.md @@ -8,9 +8,8 @@ Both placeholder templates emitted `await Encryption({ schemas: [] })`, and `Encryption` requires at least one table — an empty schema set is a deliberate compile error, so it cannot be relaxed. Every `stash init` therefore left a project whose first `tsc` or `next build` failed, in a file the CLI had just -told the user not to hand-edit. (The previous scaffold called `EncryptionV3`, -whose looser bound accepted `[]`; collapsing that into an alias of `Encryption` -tightened it.) +told the user not to hand-edit. The consolidated `Encryption` factory enforces +the non-empty schema requirement. The scaffold now declares a single sentinel table, `__stash_placeholder__`, so the file typechecks as written. Every command that reads the encryption client diff --git a/.changeset/migrate-eql-v3.md b/.changeset/migrate-eql-v3.md index 6060e6c9b..e43ea380e 100644 --- a/.changeset/migrate-eql-v3.md +++ b/.changeset/migrate-eql-v3.md @@ -11,7 +11,7 @@ naming is a convention only, never enforced or relied upon — and follow the right lifecycle, no new flags: - **`encrypt backfill`** works on v3 columns unchanged (the engine was always - version-agnostic; pass an `EncryptionV3` client and real v3 envelopes land + version-agnostic; pass an `Encryption` client and real v3 envelopes land in the concrete `eql_v3_*` domain column — verified live against a real database, including the domain CHECK and a decrypt round-trip). The manifest records the detected version, the encrypted column's name, and the diff --git a/.changeset/prisma-next-drop-encrypted-prefix.md b/.changeset/prisma-next-drop-encrypted-prefix.md index dfda43e89..50624bcac 100644 --- a/.changeset/prisma-next-drop-encrypted-prefix.md +++ b/.changeset/prisma-next-drop-encrypted-prefix.md @@ -11,16 +11,16 @@ the `cipherstash.` namespace already disambiguates. So `cipherstash.EncryptedBoolean()` → `cipherstash.Boolean()`, etc. The v3 one-call setup function is renamed `cipherstashFromStackV3` → -`cipherstashFromStack` (v3 is the default), and the existing v2 setup function -becomes `cipherstashFromStackV2`. +`cipherstashFromStack`, the package's sole setup path. The camelCase TS-authoring factory exports move in lockstep: `encryptedTextSearch` → `textSearch`, `encryptedDoubleOrd` → `doubleOrd`, etc. (a property test enforces the PSL and TS names agree modulo first-letter case). Unchanged: the runtime value envelopes (`EncryptedString`, `EncryptedNumber`, -`EncryptedBoolean`, …), the `cipherstash.*V2` legacy column constructors, the -generated `contract.json` / codec ids, and the `eql*` query operators. +`EncryptedBoolean`, …), the generated `contract.json` / codec ids, and the +`eql*` query operators. The legacy v2 constructors are removed elsewhere in +this release. The `stash-prisma-next` skill is updated to the new names (skills ship in the `stash` tarball). diff --git a/.changeset/prisma-next-v3-client-config.md b/.changeset/prisma-next-v3-client-config.md index 000bb7d41..4a6aa723e 100644 --- a/.changeset/prisma-next-v3-client-config.md +++ b/.changeset/prisma-next-v3-client-config.md @@ -3,15 +3,14 @@ --- **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: +passed through to the encryption client by `cipherstashFromStack` — uses the +v3-only `ClientConfig`. The public version-selection field has been removed, so +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'. + encryptionConfig: { /* credentials and auth options only */ }, }) ``` @@ -24,9 +23,7 @@ and `cipherstashFromStack` always builds from an all-v3 schema set, over which (`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. +To read legacy EQL v2 rows, use the returned native `encryptionClient` — +`decrypt(…)` for a single value, or the no-table `decryptModel(row)` / +`bulkDecryptModels(rows)` form for whole models. Native decrypt is +generation-agnostic even though all new writes are EQL v3. diff --git a/.changeset/protect-ffi-030-json-selectors.md b/.changeset/protect-ffi-030-json-selectors.md index 814156604..abc38f637 100644 --- a/.changeset/protect-ffi-030-json-selectors.md +++ b/.changeset/protect-ffi-030-json-selectors.md @@ -23,10 +23,10 @@ Selector-based `ORDER BY` is available as and `eqlJsonPathAsc(column, path)` / `eqlJsonPathDesc(column, path)` in Prisma Next; both lower to `ORDER BY eql_v3.ord_term` over the selected entry. -If you call `encryptQuery` with an explicit `queryType`, note that -`steVecTerm` now produces a scalar JSON ordering term. It no longer means -structural containment; use the recommended `searchableJson` query type with -an object or array for containment, or `steVecValueSelector` with +If you call `encryptQuery` with an explicit `queryType`, note that `steVecTerm` +now produces a scalar JSON ordering term. It no longer means structural +containment; use the JSON containment query type with an object or array, or +`steVecValueSelector` with `{ path, value }` for exact equality at a path. The FFI now rejects free-text needles shorter than the configured n-gram size @@ -35,9 +35,10 @@ guards. This EQL release changes the SteVec storage format. Existing EQL v3 encrypted JSON rows must be re-encrypted before they can be queried with the new domain. -Legacy EQL v2 `searchableJson()` schemas are rejected during client setup -because the old selector envelope can no longer be emitted; migrate them to the -v3 `types.Json` domain. +The former EQL v2 JSON schema shape is not accepted by the public client because +the old selector envelope can no longer be emitted; migrate to the v3 +`types.Json` domain. Native decrypt compatibility for stored v2 payloads is +unchanged. EQL 3.0.2 requires typed query-domain operands for encrypted free-text and JSON operators. PostgREST cannot express those casts, so Supabase v3 fails fast for diff --git a/.changeset/reject-v2-wire-over-v3-schemas.md b/.changeset/reject-v2-wire-over-v3-schemas.md index a4b90ccfe..c7036bde7 100644 --- a/.changeset/reject-v2-wire-over-v3-schemas.md +++ b/.changeset/reject-v2-wire-over-v3-schemas.md @@ -2,29 +2,12 @@ '@cipherstash/stack': minor --- -`Encryption({ schemas, config: { eqlVersion: 2 } })` now throws when every table -in `schemas` is an EQL v3 table, instead of building a client that writes EQL v2 -payloads into `eql_v3_*` columns. - -`EncryptionV3` used to prevent this by forcing `eqlVersion: 3` over whatever the -caller passed — the override's comment said that was why it existed. Collapsing -`EncryptionV3` into a deprecated alias of `Encryption` removed the override, and -nothing else rejected the combination: `resolveEqlVersion` already threw for a -mixed v2/v3 schema set and for legacy v2 `searchableJson()`, but returned an -explicit version unchanged. So a caller upgrading from -`EncryptionV3({ schemas: [v3Table], config: { eqlVersion: 2 } })` — previously -auto-corrected, and working — silently got a v2-wire client instead, with no -diagnostic at any layer. The type surface agreed with the runtime (both say -"nominal client"), so nothing disagreed loudly enough to notice, and the failure -surfaced later as an `eql_v3_*` domain CHECK rejecting the write, or as v2 wire -landing in a v3 column wherever the check is looser. - -The escape hatch itself is unchanged where it is actually used: an explicit -`eqlVersion: 2` over an **EQL v2** schema set still emits v2 wire, which is how -v2 payloads are minted for the read-compatibility suite. Mixed sets still throw -the existing mixing error. Reading v2 payloads is unaffected — `decrypt` and -`decryptModel` continue to read both generations regardless of the client's wire -version. - -If you hit the new error, drop `config.eqlVersion` to emit v3, or build the -client from the EQL v2 schema you actually intend to write. +`Encryption` no longer accepts a public wire-version override or legacy schema +set. It requires EQL v3 tables and always builds a client that writes EQL v3, +eliminating the configuration that could write EQL v2 payloads into +`eql_v3_*` columns. + +Reading stored EQL v2 payloads is unaffected: native `decrypt` and +`decryptModel` continue to read both generations. Compatibility fixtures mint +v2 payloads directly through the FFI rather than through the public Stack +authoring API. diff --git a/.changeset/remove-eql-v2-packages.md b/.changeset/remove-eql-v2-packages.md index 772a72e62..9d6b8e299 100644 --- a/.changeset/remove-eql-v2-packages.md +++ b/.changeset/remove-eql-v2-packages.md @@ -10,7 +10,8 @@ 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/schema` (schema builders) → the EQL v3 `encryptedTable` and + `types.*` factories from `@cipherstash/stack/eql/v3`. - `@cipherstash/protect-dynamodb` (standalone DynamoDB adapter) → `@cipherstash/stack/dynamodb` (`encryptedDynamoDB`), the maintained implementation. diff --git a/.changeset/remove-eql-v2-scaffold-examples-meta.md b/.changeset/remove-eql-v2-scaffold-examples-meta.md index 38714d679..aab7d4f8f 100644 --- a/.changeset/remove-eql-v2-scaffold-examples-meta.md +++ b/.changeset/remove-eql-v2-scaffold-examples-meta.md @@ -3,17 +3,11 @@ '@cipherstash/stack': patch --- -De-suffix the v3 client name in generated code and shipped guidance. +Use the consolidated 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. +`stash init` now scaffolds `Encryption` from `@cipherstash/stack/v3`, so a v3 +schema and its client come from one import specifier. The former suffixed client +alias has been removed from the public API. Corrects the bundled agent skills and package docs, which described `encryptedSupabase` as the legacy EQL v2 wrapper. It is the EQL v3 factory; diff --git a/.changeset/remove-eql-v2-supabase-authoring.md b/.changeset/remove-eql-v2-supabase-authoring.md index 0bf7fb5f5..9ebaed981 100644 --- a/.changeset/remove-eql-v2-supabase-authoring.md +++ b/.changeset/remove-eql-v2-supabase-authoring.md @@ -24,10 +24,10 @@ unsuffixed names (part of the EQL v2 removal, #707). 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. +migration, decrypt fetched rows with `@cipherstash/stack` directly, or use a +dedicated migration reader that calls the native client's generation-agnostic +decrypt operations. The public Stack client cannot be configured to author v2; +mixed-generation handling is explicit rather than 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 diff --git a/.changeset/skill-indexing-ord-factory.md b/.changeset/skill-indexing-ord-factory.md new file mode 100644 index 000000000..8aaef1fab --- /dev/null +++ b/.changeset/skill-indexing-ord-factory.md @@ -0,0 +1,9 @@ +--- +'stash': patch +--- + +Correct `types.TOrd` in the `stash-indexing` skill, which named a factory that +does not exist. The ordering factories are `types.Ord` (over the numeric and +time domains) and `types.TextOrd` — as the table directly above that line +already showed. An agent following the skill would have written a schema that +does not compile. diff --git a/.changeset/skills-encryption-client-naming.md b/.changeset/skills-encryption-client-naming.md index 4960a2212..3a5b264b7 100644 --- a/.changeset/skills-encryption-client-naming.md +++ b/.changeset/skills-encryption-client-naming.md @@ -3,6 +3,5 @@ --- `skills/stash-encryption` now documents how to name the client's type -(`EncryptionClientFor`, not `Awaited>`, which -always resolves to the untyped nominal client) and states that `schemas` accepts -any non-empty array of v3 tables rather than only an array literal. +(`EncryptionClient`) and states that `schemas` accepts any non-empty array of +v3 tables rather than only an array literal. diff --git a/.changeset/stack-audit-on-decrypt.md b/.changeset/stack-audit-on-decrypt.md index 48b32a60e..381d65cd2 100644 --- a/.changeset/stack-audit-on-decrypt.md +++ b/.changeset/stack-audit-on-decrypt.md @@ -25,22 +25,16 @@ context positionally (`decryptModel(item, table, lc).withLockContext(other)`) no throws instead of silently keeping the first. Pass the lock context one way or the other, not both. -**Breaking:** `EncryptionV3` is now a deprecated, type-identical alias of -`Encryption`: `Encryption` is overloaded so an array of concrete EQL v3 tables -yields the same strongly-typed client. Use `Encryption` for new code. If you were -already passing EQL v3 tables to plain `Encryption`, you now receive the typed -client rather than the nominal one — its `decryptModel` / `bulkDecryptModels` -return type changes, and the two-argument form reconstructs `Date` columns from -`cast_as` instead of leaving them as ISO strings. Code that read those columns as -strings needs updating. - -The v3 overload takes a `V3ClientConfig` — `ClientConfig` without the deprecated -`eqlVersion` escape hatch. So `Encryption({ schemas: [] })` no longer type-checks -(it used to compile and then throw), and `config: { eqlVersion: 2 }` selects the -nominal overload — though over an all-v3 schema set that call now throws at setup. -`Awaited>` names the nominal client whatever you -pass, because `ReturnType` reads the last overload; use the exported -`EncryptionClientFor` to name the client for a schema tuple. +**Breaking:** `Encryption` now accepts concrete EQL v3 tables and returns the +single strongly typed `EncryptionClient` surface. The former v3 factory and +client aliases have been removed. If you were already passing EQL v3 tables to +`Encryption`, model decrypt now reconstructs `Date` columns from `cast_as` +instead of leaving them as ISO strings. Code that read those columns as strings +needs updating. + +`Encryption({ schemas: [] })` no longer type-checks (it used to compile and then +throw). The public config no longer selects an EQL wire version: new clients +always author EQL v3. Name a client for a schema set as `EncryptionClient`. `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 @@ -48,7 +42,5 @@ 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. +The public EQL v2 schema builders and version-selection config have been removed. +Native decrypt operations remain able to read legacy EQL v2 payloads. diff --git a/.changeset/stack-dynamodb-v2-write-removal.md b/.changeset/stack-dynamodb-v2-write-removal.md index 445bd3729..0dbbafd9c 100644 --- a/.changeset/stack-dynamodb-v2-write-removal.md +++ b/.changeset/stack-dynamodb-v2-write-removal.md @@ -7,12 +7,10 @@ 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. +**Decrypt still reads existing v2 items.** Pass the corresponding EQL v3 table +and `{ storedEqlVersion: 2 }` to `decryptModel` / `bulkDecryptModels`; the adapter +uses that explicit storage-version hint for legacy envelope reconstruction. 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. +`@cipherstash/stack/eql/v3`) and reuse that table for reads, adding the explicit +legacy-read option only while reading stored v2 data. diff --git a/.changeset/stack-skills-eql-v3-audit.md b/.changeset/stack-skills-eql-v3-audit.md index f63f9b085..ed502a0b6 100644 --- a/.changeset/stack-skills-eql-v3-audit.md +++ b/.changeset/stack-skills-eql-v3-audit.md @@ -6,8 +6,9 @@ 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. + a separate nominal client for audited decrypts" caveat is removed. + Encrypt/write is EQL v3 only; legacy DynamoDB reads pass a v3 table with + `{ storedEqlVersion: 2 }`. +- `stash-encryption`: canonical examples use `Encryption` and the generic + `EncryptionClient` type; the DynamoDB notes state encrypt is v3-only while + native decrypt still reads stored v2 payloads. diff --git a/.changeset/supabase-v2-table-diagnosis.md b/.changeset/supabase-v2-table-diagnosis.md index 1c6cfb160..584c58c15 100644 --- a/.changeset/supabase-v2-table-diagnosis.md +++ b/.changeset/supabase-v2-table-diagnosis.md @@ -4,22 +4,18 @@ 'stash': patch --- -Diagnose an EQL v2 table by name instead of crashing with a raw `TypeError`. +Diagnose a legacy EQL v2 table shape by name instead of crashing with a raw +`TypeError`. -A v2 `EncryptedTable` is structurally identical to a v3 one — same `tableName`, -same `columnBuilders` — and only `buildColumnKeyMap()` tells them apart. Passing -one to `encryptedSupabase({ schemas })` therefore sailed past every check that -looked at shape and died deep inside verification as `builder.getEqlType is not -a function`, naming an internal method rather than the version mismatch that -caused it. Constructing the query builder directly failed the same way one layer -down, as `table.buildColumnKeyMap is not a function`. +A table created by the former v2 API is structurally similar to a v3 one. Old +compiled code or untyped JavaScript could therefore pass that shape to +`encryptedSupabase({ schemas })` and fail deep inside verification, naming an +internal method rather than the version mismatch that caused it. Both paths now fail closed with the table named and the fix stated. The check routes through `hasBuildColumnKeyMap`, the canonical v2/v3 discriminator, rather than a second hand-written spelling of it. -`@cipherstash/stack` re-exports `hasBuildColumnKeyMap` from -`@cipherstash/stack/adapter-kit` so first-party adapters can make that routing -decision without reaching into internals. It is deliberately not on `./types`: -deciding which wire version a table targets is adapter plumbing, not end-user -API. +First-party adapters share an internal discriminator through +`@cipherstash/stack/adapter-kit`; it is adapter plumbing rather than an +end-user schema-authoring API. diff --git a/.changeset/typed-client-init-parity.md b/.changeset/typed-client-init-parity.md index ecc41b3b5..9c706fc9f 100644 --- a/.changeset/typed-client-init-parity.md +++ b/.changeset/typed-client-init-parity.md @@ -2,36 +2,15 @@ '@cipherstash/stack': patch --- -The typed EQL v3 client no longer throws `TypeError: init is not a function`. - -`Encryption` selects its overload from the `config` argument but selects the -client it actually returns by inspecting the `schemas`, so the two can disagree. -Hoisting a config into a `ClientConfig`-typed variable is enough to split them — -`ClientConfig.eqlVersion` is `2 | 3`, which the v3 overload's `eqlVersion?: 3` -rejects — so the call types as the nominal `EncryptionClient` while the runtime -still returns the typed client: +`Encryption` now exposes one EQL v3-only construction path and returns +`EncryptionClient` consistently for the supplied schemas: ```ts const config: ClientConfig = { keyset } const client = await Encryption({ schemas: [users], config }) -// type: EncryptionClient · runtime: TypedEncryptionClient +// type and runtime share the same generic EncryptionClient surface ``` -`init` was the only member of `EncryptionClient` missing from the typed client, -so any call through the declared type crashed. It is now present — and pins the -wire format rather than delegating verbatim. - -That distinction matters. `EncryptionClient.init` takes its own `eqlVersion` and -forwards `undefined` to the FFI, whose default is EQL **v2**, and it never -consults the check `Encryption` runs at construction. A bare passthrough would -therefore have let a typed v3 client be re-initialised into v2 wire while keeping -its v3 surface — writing `eql_v2_encrypted` payloads into `eql_v3_*` columns, the -same contradiction refused at construction, one method call later. The typed -`init` pins `eqlVersion: 3`, refuses an explicit `2` with a failure `Result`, and -resolves to the **typed** client, so the common -`client = (await client.init(cfg)).data` does not silently swap the typed surface -for the nominal one. - -The type still under-reports in this case: you lose the typed surface with no -diagnostic. Pass the config inline, or type the variable as `V3ClientConfig`, to -keep it. +The public client no longer exposes a separate `init` method or a config option +that can select EQL v2. Initialization happens through `Encryption`, which +always configures EQL v3 and prevents the former type/runtime split. diff --git a/.changeset/v3-only-config-typing-and-entry-parity.md b/.changeset/v3-only-config-typing-and-entry-parity.md new file mode 100644 index 000000000..6f5822aa6 --- /dev/null +++ b/.changeset/v3-only-config-typing-and-entry-parity.md @@ -0,0 +1,42 @@ +--- +'@cipherstash/stack': major +'@cipherstash/prisma-next': patch +'stash': patch +--- + +Close the gaps found reviewing the v3-only change against #815's acceptance +criteria. + +`config.eqlVersion` is now rejected by the type system as well as at runtime, on +both entries. `ClientConfig.eqlVersion` and `WasmClientConfig.eqlVersion` are +declared `?: never` rather than omitted: every other property on those types is +optional, so excess-property checking was the only thing catching a leftover +`eqlVersion` — and that fires on fresh object literals alone. A shared config +const, which is the shape a v2 → v3 migration actually holds, type-checked clean +and then threw at `Encryption()`. It is now a compile error. Both entries keep +their runtime guard, since JS and JSON callers bypass types entirely. + +`@cipherstash/stack/wasm-inline` now rejects `config.eqlVersion` at runtime too, +with the same message as the native entry. Previously the native factory threw +and the WASM one accepted the field silently — the entry disagreement #815 exists +to remove. + +The WASM entry's non-v3-table error no longer refers the reader to the native +entry for EQL v2 authoring. Authoring v2 has been removed everywhere, so that +referral only bought a second rejection; the message now says so and points at +what v2 payloads are still good for — decryption, which is unchanged. + +The `Encryption` signature sketch in the `@cipherstash/stack` README carried +`schemas: AnyV3Table[]`, understating what is accepted; it now shows both real +overloads, including the `readonly` and non-literal array forms. The bundled +`stash-encryption` skill regained the `./encryption` and `./adapter-kit` subpath +rows, both of which still ship. `cipherstashFromStack`'s `encryptionConfig` +JSDoc described `config.eqlVersion` as an escape hatch that throws over an +all-v3 schema set; it is rejected unconditionally, and the doc now says that. + +The `stash-dynamodb` skill documented the v3 descriptor a legacy read takes but +not that it must also be one of the tables passed to `Encryption({ schemas })`. +The adapter forwards that descriptor to the client, which rejects a table it was +not initialized with, so reading v2 rows for a table your current schema no +longer declares fails. That requirement is now stated where the legacy-read +signature is. diff --git a/.changeset/v3-only-encryption-client.md b/.changeset/v3-only-encryption-client.md new file mode 100644 index 000000000..f6ab4e055 --- /dev/null +++ b/.changeset/v3-only-encryption-client.md @@ -0,0 +1,15 @@ +--- +'@cipherstash/stack': major +'@cipherstash/stack-supabase': major +'@cipherstash/prisma-next': major +'stash': patch +--- + +Make `Encryption` and schema authoring EQL v3-only. The client now always writes +EQL v3, exposes the single generic `EncryptionClient` type, and removes the +legacy v2 builders, client aliases, `config.eqlVersion`, and `./client` subpath. + +Native decrypt operations continue to read stored EQL v2 payloads. DynamoDB +legacy reads now use a v3 table descriptor with `{ storedEqlVersion: 2 }`. +Update the Supabase and Prisma Next integrations and the bundled agent skills +for the consolidated API. diff --git a/.changeset/v3-only-review-followups.md b/.changeset/v3-only-review-followups.md new file mode 100644 index 000000000..7475705b3 --- /dev/null +++ b/.changeset/v3-only-review-followups.md @@ -0,0 +1,30 @@ +--- +'@cipherstash/stack': patch +--- + +Three fixes to the EQL v3-only surface. + +**DynamoDB: grouped v2 `date` / `timestamp` columns now reconstruct to `Date`.** +A legacy grouped field is stored as `.__source` while the v2 schema +knew it only as ``, so a `{ storedEqlVersion: 2 }` read matches it by its +bare leaf and writes the plaintext back at the nested path (`details.placedAt`). +Both clients resolve their date columns from the *declared* paths, so neither +reconstructed that value and it came back as an ISO string. The read path now +reports the path it actually wrote to and the adapter reconstructs there — +covering the native and `wasm-inline` entries, on `decryptModel` and +`bulkDecryptModels` alike. Carrying a grouped date forward as a plain top-level +column no longer requires re-declaring it as a dotted path. + +**`EncryptionClientConfig` no longer accepts an empty schema set.** Its default +type argument widened to `readonly AnyV3Table[]`, where `S['length']` resolves to +`number` and the non-empty conditional stopped firing, so +`const cfg: EncryptionClientConfig = { schemas: [] }` typechecked and then threw +at `Encryption(cfg)`. The default is a non-empty tuple again, matching +`WasmEncryptionConfig`. The factory still accepts widened arrays passed inline. + +**`config.eqlVersion: undefined` is tolerated at runtime.** `eqlVersion?: never` +admits an explicit `undefined` (this repo does not enable +`exactOptionalPropertyTypes`, and no declaration can reject it), but both +factories threw on the mere presence of the key — failing a config the published +types accept. An explicit `undefined` names no version and is now allowed; every +real value, `eqlVersion: 3` included, is still rejected. diff --git a/.changeset/wasm-encryption-schema-arrays.md b/.changeset/wasm-encryption-schema-arrays.md new file mode 100644 index 000000000..188b74f52 --- /dev/null +++ b/.changeset/wasm-encryption-schema-arrays.md @@ -0,0 +1,19 @@ +--- +'@cipherstash/stack': patch +--- + +The `@cipherstash/stack/wasm-inline` `Encryption` factory now accepts the same +schema-array shapes the native entry does. + +`WasmEncryptionConfig.schemas` was a mutable non-empty tuple +(`[AnyV3Table, ...AnyV3Table[]]`), which rejects every form that is not an array +literal: a shared `export const all: AnyV3Table[]`, a `ReadonlyArray`, a +`.map()` result, anything spread or push-built. The native factory was widened +to `readonly AnyV3Table[]` for exactly that reason; the WASM twin kept the +tuple, so the two entries disagreed about identical calls while their runtimes +agreed exactly (both check only `schemas.length`). + +Non-emptiness moves to the `Encryption` overloads, so `Encryption({ schemas: [] })` +remains a compile error on this entry too. The built `wasm-inline.d.ts` is now +covered by the declaration gate, which is where this drift went unnoticed — the +entry gets its own tsup DTS pass that no source-level type test can see. diff --git a/.github/workflows/integration-drizzle.yml b/.github/workflows/integration-drizzle.yml index c4b3e7669..7f642d26e 100644 --- a/.github/workflows/integration-drizzle.yml +++ b/.github/workflows/integration-drizzle.yml @@ -26,6 +26,31 @@ on: - 'packages/stack/integration/**' # The WASM family suite (integration/wasm/**) exercises this entry: - 'packages/stack/src/wasm-inline.ts' + # The DynamoDB adapter, and the entry/type modules the suites import + # directly. `integration/shared/v2-decrypt-compat` and its + # `integration/wasm/` twin (both selected below) are the repo's only live + # EQL v2 read coverage, and they exercise the DynamoDB legacy path, so a + # change here must run them. Pinned by + # scripts/__tests__/integration-workflow-paths.test.mjs. + - 'packages/stack/src/dynamodb/**' + - 'packages/stack/src/index.ts' + - 'packages/stack/src/types.ts' + # Those same v2 suites mint their fixtures by importing + # `@cipherstash/protect-ffi` directly. A native-module bump is the change + # most able to break v2 payload deserialization and it touches NO source + # directory, so without these two entries the only suites that would catch + # it never start. They are the files a bump actually edits: exact pins + # (`protect-ffi`, `@cipherstash/eql`) live in the package manifest, + # `catalog:` ones (`@cipherstash/auth`, which moves in lockstep with + # protect-ffi for the WASM entry) in the workspace catalog. + # + # `pnpm-lock.yaml` is deliberately NOT listed. It changes on roughly every + # dependency bump in the monorepo — far more often than either file here — + # and these are credentialed, database-backed jobs. Nothing is lost: a + # protect-ffi or auth version change cannot reach the lockfile without + # editing one of the two manifests below first. + - 'packages/stack/package.json' + - 'pnpm-workspace.yaml' - 'packages/test-kit/**' - 'packages/cli/src/installer/**' - 'local/docker-compose.postgres.yml' @@ -48,6 +73,31 @@ on: - 'packages/stack/integration/**' # The WASM family suite (integration/wasm/**) exercises this entry: - 'packages/stack/src/wasm-inline.ts' + # The DynamoDB adapter, and the entry/type modules the suites import + # directly. `integration/shared/v2-decrypt-compat` and its + # `integration/wasm/` twin (both selected below) are the repo's only live + # EQL v2 read coverage, and they exercise the DynamoDB legacy path, so a + # change here must run them. Pinned by + # scripts/__tests__/integration-workflow-paths.test.mjs. + - 'packages/stack/src/dynamodb/**' + - 'packages/stack/src/index.ts' + - 'packages/stack/src/types.ts' + # Those same v2 suites mint their fixtures by importing + # `@cipherstash/protect-ffi` directly. A native-module bump is the change + # most able to break v2 payload deserialization and it touches NO source + # directory, so without these two entries the only suites that would catch + # it never start. They are the files a bump actually edits: exact pins + # (`protect-ffi`, `@cipherstash/eql`) live in the package manifest, + # `catalog:` ones (`@cipherstash/auth`, which moves in lockstep with + # protect-ffi for the WASM entry) in the workspace catalog. + # + # `pnpm-lock.yaml` is deliberately NOT listed. It changes on roughly every + # dependency bump in the monorepo — far more often than either file here — + # and these are credentialed, database-backed jobs. Nothing is lost: a + # protect-ffi or auth version change cannot reach the lockfile without + # editing one of the two manifests below first. + - 'packages/stack/package.json' + - 'pnpm-workspace.yaml' - '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 f5eff3093..25857b159 100644 --- a/.github/workflows/integration-supabase.yml +++ b/.github/workflows/integration-supabase.yml @@ -20,6 +20,30 @@ on: - 'packages/stack/src/encryption/**' - 'packages/stack/src/schema/**' - 'packages/stack/integration/**' + # The DynamoDB adapter, and the entry/type modules the suites import + # directly. `integration/shared/v2-decrypt-compat` is the repo's only live + # EQL v2 read coverage for the native entry (the `integration/wasm/` twin + # runs on the Drizzle job) and it exercises the DynamoDB legacy path, so a + # change here must run it. Pinned by + # scripts/__tests__/integration-workflow-paths.test.mjs. + - 'packages/stack/src/dynamodb/**' + - 'packages/stack/src/index.ts' + - 'packages/stack/src/types.ts' + # That v2 suite mints its fixtures by importing `@cipherstash/protect-ffi` + # directly. A native-module bump is the change most able to break v2 + # payload deserialization and it touches NO source directory, so without + # these two entries the only suites that would catch it never start. They + # are the files a bump actually edits: exact pins (`protect-ffi`, + # `@cipherstash/eql`) live in the package manifest, `catalog:` ones + # (`@cipherstash/auth`) in the workspace catalog. + # + # `pnpm-lock.yaml` is deliberately NOT listed. It changes on roughly every + # dependency bump in the monorepo — far more often than either file here — + # and these are credentialed, database-backed jobs. Nothing is lost: a + # protect-ffi or auth version change cannot reach the lockfile without + # editing one of the two manifests below first. + - 'packages/stack/package.json' + - 'pnpm-workspace.yaml' - 'packages/test-kit/**' - 'packages/cli/src/installer/**' - 'local/docker-compose.supabase.yml' @@ -38,6 +62,30 @@ on: - 'packages/stack/src/encryption/**' - 'packages/stack/src/schema/**' - 'packages/stack/integration/**' + # The DynamoDB adapter, and the entry/type modules the suites import + # directly. `integration/shared/v2-decrypt-compat` is the repo's only live + # EQL v2 read coverage for the native entry (the `integration/wasm/` twin + # runs on the Drizzle job) and it exercises the DynamoDB legacy path, so a + # change here must run it. Pinned by + # scripts/__tests__/integration-workflow-paths.test.mjs. + - 'packages/stack/src/dynamodb/**' + - 'packages/stack/src/index.ts' + - 'packages/stack/src/types.ts' + # That v2 suite mints its fixtures by importing `@cipherstash/protect-ffi` + # directly. A native-module bump is the change most able to break v2 + # payload deserialization and it touches NO source directory, so without + # these two entries the only suites that would catch it never start. They + # are the files a bump actually edits: exact pins (`protect-ffi`, + # `@cipherstash/eql`) live in the package manifest, `catalog:` ones + # (`@cipherstash/auth`) in the workspace catalog. + # + # `pnpm-lock.yaml` is deliberately NOT listed. It changes on roughly every + # dependency bump in the monorepo — far more often than either file here — + # and these are credentialed, database-backed jobs. Nothing is lost: a + # protect-ffi or auth version change cannot reach the lockfile without + # editing one of the two manifests below first. + - 'packages/stack/package.json' + - 'pnpm-workspace.yaml' - 'packages/test-kit/**' - 'packages/cli/src/installer/**' - 'local/docker-compose.supabase.yml' diff --git a/AGENTS.md b/AGENTS.md index 2ce2f337b..236ee15aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ If these variables are missing, tests that require live encryption will fail or ## Repository Layout - `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) + - Subpath exports: `@cipherstash/stack`, `@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/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 @@ -144,8 +144,8 @@ Three rules to remember when editing CI or pnpm config: ## Key Concepts and APIs -- **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. +- **Initialization**: `Encryption({ schemas })` is the single client factory. It requires at least one concrete EQL v3 `encryptedTable` and returns `EncryptionClient`, whose model and query types are derived from that schema tuple. The `EncryptionV3`, `typedClient`, `EncryptionClientFor`, and nominal-client surfaces have been removed. +- **Schema**: 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 `Encryption`, `encryptedTable`, and `types` from `@cipherstash/stack/v3`). Schema authoring and all writes are EQL v3-only; `config.eqlVersion` and the public EQL v2 schema builders have been removed. Both the native and `wasm-inline` clients still decrypt existing v2 payloads. DynamoDB legacy reads use the same v3 table descriptor plus `{ storedEqlVersion: 2 }`, and the table must be one given to `Encryption({ schemas })`; nested v3 fields use a flat dotted column path (`'profile.ssn': types.TextEq(...)`). - **Operations** (all return Result-like objects and support chaining `.withLockContext(lockContext)` and `.audit()` when applicable): - `encrypt(plaintext, { table, column })` - `decrypt(encryptedPayload)` diff --git a/examples/prisma/src/db.ts b/examples/prisma/src/db.ts index af73e7042..df793398f 100644 --- a/examples/prisma/src/db.ts +++ b/examples/prisma/src/db.ts @@ -4,7 +4,7 @@ * * `cipherstashFromStack({ contractJson })` derives the v3 encryption * schemas from the contract (one `public.eql_v3_*` domain per column), - * constructs the `@cipherstash/stack` `EncryptionV3` client against + * constructs the `@cipherstash/stack` encryption client against * your `CS_*` env vars or local profile, builds the SDK adapter, and * returns ready-to-spread arrays for `extensions` and `middleware`. * Override `schemasV3` only if you have additional tables the contract diff --git a/packages/bench/src/drizzle/setup.ts b/packages/bench/src/drizzle/setup.ts index 9aa08bae5..b91d2ef0d 100644 --- a/packages/bench/src/drizzle/setup.ts +++ b/packages/bench/src/drizzle/setup.ts @@ -1,4 +1,4 @@ -import { type EncryptionClientFor, EncryptionV3 } from '@cipherstash/stack/v3' +import { Encryption, type EncryptionClient } 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' @@ -23,7 +23,7 @@ export const benchTable = pgTable('bench', { }) /** - * Encryption schema for the `EncryptionV3()` client. Derived from the Drizzle + * Encryption schema for the `Encryption()` client. Derived from the Drizzle * table above so the two can't drift apart. */ export const encryptionBenchTable = extractEncryptionSchema(benchTable) @@ -55,7 +55,7 @@ export type BenchPlaintextRow = { } /** The typed EQL v3 client this bench drives. */ -export type BenchEncryptionClient = EncryptionClientFor< +export type BenchEncryptionClient = EncryptionClient< readonly [typeof encryptionBenchTable] > @@ -79,7 +79,7 @@ export async function buildBench(): Promise { const db = drizzle(pool) - const encryptionClient = await EncryptionV3({ + const encryptionClient = await Encryption({ schemas: [encryptionBenchTable], }) 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 59067ee14..21d0d929c 100644 --- a/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts +++ b/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts @@ -20,7 +20,7 @@ describe('generateClientFromSchemas', () => { expect(out).toContain("age: types.IntegerOrd('age'),") expect(out).toContain("verified: types.Boolean('verified'),") expect(out).toContain("from '@cipherstash/stack/v3'") - // `Encryption` is the current name; `EncryptionV3` is a deprecated alias. + // `Encryption` is the sole public factory; the old alias is removed. // 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. diff --git a/packages/migrate/src/__tests__/backfill-v3.integration.test.ts b/packages/migrate/src/__tests__/backfill-v3.integration.test.ts index 4c426f282..934a032b2 100644 --- a/packages/migrate/src/__tests__/backfill-v3.integration.test.ts +++ b/packages/migrate/src/__tests__/backfill-v3.integration.test.ts @@ -83,7 +83,7 @@ describe.skipIf(!runIntegration)('runBackfill with EQL v3 payloads', () => { await pool.end() }) - /** v3 FLAT SCALAR marker — the shape `EncryptionV3` clients emit for + /** v3 FLAT SCALAR marker — the shape `Encryption` clients emit for * text/number columns: no `k` discriminator, top-level `c`. */ const v3ScalarClient: EncryptionClientLike = { bulkEncryptModels(input) { diff --git a/packages/prisma-next/DEVELOPING.md b/packages/prisma-next/DEVELOPING.md index 68c8e573a..78aa21937 100644 --- a/packages/prisma-next/DEVELOPING.md +++ b/packages/prisma-next/DEVELOPING.md @@ -41,8 +41,8 @@ packages/prisma-next/ │ │ ├── query-term.ts the query-term seam (mark / collect / route) │ │ ├── bulk-encrypt-v3.ts bulkEncryptMiddlewareV3(sdk) │ │ ├── runtime-v3.ts createCipherstashV3RuntimeDescriptor({ sdk }) -│ │ ├── derive-schemas-v3.ts contract.json → EncryptedTable[] for EncryptionV3 -│ │ ├── sdk-adapter-v3.ts EncryptionV3 client → CipherstashSdk adapter +│ │ ├── derive-schemas-v3.ts contract.json → EncryptedTable[] for Encryption +│ │ ├── sdk-adapter-v3.ts Encryption client → CipherstashSdk adapter │ │ ├── from-stack-v3-validate.ts assertV3SchemasAgree (override-vs-contract check) │ │ └── barrel.ts user-facing envelope re-exports │ ├── stack/ @@ -283,7 +283,7 @@ params are invisible to the middleware. the one-call factory: it derives the v3 encryption schemas from the contract (`deriveStackSchemasV3` — one `public.eql_v3_*` domain per column, selected by `nativeType` via `V3_FACTORY_BY_NATIVE_TYPE`), -constructs the `@cipherstash/stack` `EncryptionV3` client, adapts it to +constructs the `@cipherstash/stack` `Encryption` client, adapts it to `CipherstashSdk` (`src/v3/sdk-adapter-v3.ts`), and returns ready-to-spread `extensions` / `middleware` for `postgres({...})`. It rejects a contract carrying non-v3 cipherstash codec ids — the package is v3 only. @@ -386,7 +386,7 @@ without an SDK round-trip. The interface declares three async methods (`decrypt`, `bulkEncrypt`, `bulkDecrypt`), each accepting an optional `AbortSignal`, with polymorphic (`unknown`) value types. It is deliberately smaller than the -upstream `EncryptionV3` client, so real usage wraps that client behind a +upstream `Encryption` client, so real usage wraps that client behind a thin adapter (`src/v3/sdk-adapter-v3.ts`) and the framework-side surface stays free of upstream-specific types. diff --git a/packages/prisma-next/src/exports/stack.ts b/packages/prisma-next/src/exports/stack.ts index d72ce3254..59276f1dc 100644 --- a/packages/prisma-next/src/exports/stack.ts +++ b/packages/prisma-next/src/exports/stack.ts @@ -4,7 +4,7 @@ * * Most consumers want {@link cipherstashFromStack}: it derives the v3 * encryption schemas from your contract, constructs the - * `@cipherstash/stack` `EncryptionV3` client from your `CS_*` env vars or + * `@cipherstash/stack` `Encryption` client from your `CS_*` env vars or * local profile, builds the SDK adapter, and returns ready-to-spread * `extensions` / `middleware` for `postgres({...})`. The * remaining exports are the primitives it composes, for advanced users diff --git a/packages/prisma-next/src/stack/from-stack-v3.ts b/packages/prisma-next/src/stack/from-stack-v3.ts index 071446b4d..c144d5c22 100644 --- a/packages/prisma-next/src/stack/from-stack-v3.ts +++ b/packages/prisma-next/src/stack/from-stack-v3.ts @@ -25,8 +25,8 @@ */ import type { AnyV3Table } from '@cipherstash/stack/eql/v3' -import type { V3ClientConfig } from '@cipherstash/stack/types' -import { EncryptionV3, type TypedEncryptionClient } from '@cipherstash/stack/v3' +import type { ClientConfig } from '@cipherstash/stack/types' +import { Encryption, type EncryptionClient } from '@cipherstash/stack/v3' import type { SqlMiddleware, SqlRuntimeExtensionDescriptor, @@ -56,13 +56,18 @@ export interface CipherstashFromStackV3Options { readonly schemasV3?: ReadonlyArray /** - * Pass-through to `EncryptionV3({ config })` (keyset overrides, logging, …). + * Pass-through to `Encryption({ config })` (keyset overrides, logging, …). * - * `V3ClientConfig`, not `ClientConfig`: this package is EQL v3 only, and the - * legacy `eqlVersion: 2` escape hatch throws at setup over the all-v3 schema - * set this entry point derives. + * `ClientConfig` no longer carries an `eqlVersion` field — `@cipherstash/stack` + * always authors EQL v3 — so there is no v2 escape hatch to reach from here. + * `Encryption()` throws on the mere PRESENCE of the key + * (`` `config.eqlVersion` has been removed ``), whatever its value — an + * explicit `undefined` excepted, since the type admits that one and cannot + * reject it — and whatever the schema set, so a config object carrying a + * leftover `eqlVersion` fails at setup rather than silently selecting a wire + * format. */ - readonly encryptionConfig?: V3ClientConfig + readonly encryptionConfig?: ClientConfig } export interface CipherstashFromStackV3Result { @@ -71,10 +76,10 @@ export interface CipherstashFromStackV3Result { /** Ready to spread into `postgres({ middleware })`. */ readonly middleware: ReadonlyArray /** - * The initialised v3 `TypedEncryptionClient` for direct SDK access + * The initialised v3 `EncryptionClient` for direct SDK access * outside the ORM path (`encryptModel`, `encryptQuery`, …). */ - readonly encryptionClient: TypedEncryptionClient + readonly encryptionClient: EncryptionClient } export async function cipherstashFromStack( @@ -100,7 +105,7 @@ export async function cipherstashFromStack( const schemas = resolveV3Schemas(derived, opts.schemasV3) - const encryptionClient = await EncryptionV3({ + const encryptionClient = await Encryption({ schemas, ...(opts.encryptionConfig !== undefined ? { config: opts.encryptionConfig } diff --git a/packages/prisma-next/src/v3/derive-schemas-v3.ts b/packages/prisma-next/src/v3/derive-schemas-v3.ts index a4df3127b..5bc82a75c 100644 --- a/packages/prisma-next/src/v3/derive-schemas-v3.ts +++ b/packages/prisma-next/src/v3/derive-schemas-v3.ts @@ -9,7 +9,7 @@ * domain as `nativeType`. `deriveStackSchemasV3` walks the * `storage.namespaces..entries.table` envelope and returns one v3 * `EncryptedTable` per table with at least one v3-codec'd column, ready - * to pass to `EncryptionV3({ schemas })`. + * to pass to `Encryption({ schemas })`. * * The v3-specific delta vs the v2 derivation: the concrete column * factory is selected by `nativeType` via the catalog's @@ -112,7 +112,7 @@ export function v3ContractColumnEntries( * Derive an array of v3 `EncryptedTable` builders from a Prisma Next * contract. Returns an empty array when no v3 cipherstash columns are * present; callers must still pass at least one table to - * `EncryptionV3({ schemas })`, which requires a non-empty array. + * `Encryption({ schemas })`, which requires a non-empty array. */ export function deriveStackSchemasV3( contractJson: V3ContractShape, diff --git a/packages/prisma-next/src/v3/sdk-adapter-v3.ts b/packages/prisma-next/src/v3/sdk-adapter-v3.ts index fa189bb46..d06394bb6 100644 --- a/packages/prisma-next/src/v3/sdk-adapter-v3.ts +++ b/packages/prisma-next/src/v3/sdk-adapter-v3.ts @@ -1,6 +1,6 @@ /** * Adapt the `@cipherstash/stack` EQL v3 client (the - * `TypedEncryptionClient` returned by `EncryptionV3`) to the + * `EncryptionClient` returned by `Encryption`) to the * framework-native `CipherstashSdk` shape consumed by * `createCipherstashV3RuntimeDescriptor({ sdk })` and * `bulkEncryptMiddlewareV3(sdk)`. @@ -60,7 +60,7 @@ type StackResult = /** * Minimal structural view of the stack v3 client this adapter drives — - * satisfied by the `TypedEncryptionClient` that `EncryptionV3` returns + * satisfied by the `EncryptionClient` that `Encryption` returns * (whatever its schema tuple) AND by a hand-rolled test double, neither * needing a cast. * @@ -104,7 +104,7 @@ interface PendingSlot { * it was constructed with. * * `schemas` should be the exact `AnyV3Table[]` passed to - * `EncryptionV3({ schemas })` (typically the return value of + * `Encryption({ schemas })` (typically the return value of * `deriveStackSchemasV3`). The adapter uses it to translate framework * `(table, column)` routing-key strings back to the typed schema * objects the client's operations expect. diff --git a/packages/prisma-next/test/live/bulk-encrypt-live-pg.test.ts b/packages/prisma-next/test/live/bulk-encrypt-live-pg.test.ts index a2b34e813..015cfc1af 100644 --- a/packages/prisma-next/test/live/bulk-encrypt-live-pg.test.ts +++ b/packages/prisma-next/test/live/bulk-encrypt-live-pg.test.ts @@ -1,6 +1,6 @@ /** * Live-PG bulk-encrypt middleware: the REAL `bulkEncryptMiddlewareV3` - * instance wired by `cipherstashFromStack` (real `EncryptionV3` + * instance wired by `cipherstashFromStack` (real `Encryption` * client, not a fake) drives a multi-row INSERT. Proves end-to-end: * the AST walk stamps `(table, column)` routing keys, the per-column * batch makes ONE bulkEncrypt crossing, the param slots are replaced diff --git a/packages/prisma-next/test/live/helpers/harness.ts b/packages/prisma-next/test/live/helpers/harness.ts index e19fb8647..0e1ea4708 100644 --- a/packages/prisma-next/test/live/helpers/harness.ts +++ b/packages/prisma-next/test/live/helpers/harness.ts @@ -4,7 +4,7 @@ * Everything on the encryption path is REAL: * * - the client is `cipherstashFromStack({ contractJson })` over a - * real `EncryptionV3` (ZeroKMS round-trips, no fakes); + * real `Encryption` (ZeroKMS round-trips, no fakes); * - writes go through the REAL v3 bulk-encrypt middleware returned by * that factory (`cs.middleware[0].beforeExecute`), driven exactly * the way the SQL runtime drives it: an execution plan whose AST diff --git a/packages/prisma-next/test/v3/from-stack-v3.test.ts b/packages/prisma-next/test/v3/from-stack-v3.test.ts index f172afe22..484a5f28f 100644 --- a/packages/prisma-next/test/v3/from-stack-v3.test.ts +++ b/packages/prisma-next/test/v3/from-stack-v3.test.ts @@ -1,6 +1,6 @@ /** * `cipherstashFromStack` — the v3-only entry point's validation - * paths, all of which throw BEFORE any `EncryptionV3` client is + * paths, all of which throw BEFORE any `Encryption` client is * constructed (so no live CipherStash credentials are needed here; the * happy path is exercised by the live suite). * diff --git a/packages/prisma-next/tsconfig.json b/packages/prisma-next/tsconfig.json index 6be0e4b91..e79e9325c 100644 --- a/packages/prisma-next/tsconfig.json +++ b/packages/prisma-next/tsconfig.json @@ -35,7 +35,6 @@ // surfaces as implicit-`any` noise far from the real cause // (#684). The matching runtime aliases live in `vitest.shared.ts`. "@cipherstash/stack": ["../stack/src/index.ts"], - "@cipherstash/stack/client": ["../stack/src/client.ts"], "@cipherstash/stack/types": ["../stack/src/types-public.ts"], "@cipherstash/stack/eql/v3": ["../stack/src/eql/v3/index.ts"], "@cipherstash/stack/schema": ["../stack/src/schema/index.ts"], diff --git a/packages/stack-drizzle/__tests__/operators.test-d.ts b/packages/stack-drizzle/__tests__/operators.test-d.ts index 600a62d63..43da25c3b 100644 --- a/packages/stack-drizzle/__tests__/operators.test-d.ts +++ b/packages/stack-drizzle/__tests__/operators.test-d.ts @@ -1,27 +1,44 @@ import type { Result } from '@byteslice/result' import type { AuditConfig } from '@cipherstash/stack/adapter-kit' -import type { EncryptionClient } from '@cipherstash/stack/encryption' +import { encryptedTable, types } from '@cipherstash/stack/eql/v3' import type { EncryptionError } from '@cipherstash/stack/errors' import type { LockContext } from '@cipherstash/stack/identity' import type { EncryptedQueryResult } from '@cipherstash/stack/types' -import type { AnyV3Table, EncryptionClientFor } from '@cipherstash/stack/v3' +import type { EncryptionClient } from '@cipherstash/stack/v3' import { describe, expectTypeOf, it } from 'vitest' import { createEncryptionOperators } from '../src/index.js' /** - * Static regression guard for M1: `createEncryptionOperators` must accept the - * `TypedEncryptionClient` that `EncryptionV3` resolves to — the documented - * `createEncryptionOperators(await EncryptionV3({ schemas }))` usage — as well - * as the nominal `EncryptionClient` and a hand-rolled `{ encryptQuery }` double, - * none requiring a cast. Typing the parameter to `EncryptionClient` (the - * original bug) makes the first call below a compile error, which this suite - * would then catch. Every operand is now encrypted with `encryptQuery` (#622), - * so the operand client contract is `encryptQuery` in both its single and batch - * forms; the doubles model that. Lives in a `*.test-d.ts` so it is inside the - * existing typecheck scope without dragging the loose-typed runtime suites in. + * Static regression guard for M1: `createEncryptionOperators` must accept every + * client shape a caller can hand it, none requiring a cast — + * + * 1. the DEFAULTED `EncryptionClient`. Its schema-tuple parameter defaults to + * `readonly AnyV3Table[]`, so `EncryptionClient` and + * `EncryptionClient` are the SAME type — asserting + * both would be one assertion written twice, which is why only one appears. + * 2. `EncryptionClient` — the distinct instantiation + * `Encryption({ schemas })` actually returns, narrowed to exactly the tables + * it was given. This is the documented + * `createEncryptionOperators(await Encryption({ schemas }))` usage. + * 3. a hand-rolled `{ encryptQuery, encrypt }` double. This is the case with + * real teeth: the two client instantiations above are mutually assignable + * (the interface's members are declared method-style, so TypeScript relates + * them bivariantly), but the double is NOT assignable to `EncryptionClient` + * — it is missing `encryptModel`, `decrypt`, `decryptModel` and five more. + * So it is the double, not schema-tuple width, that forces the factory's + * parameter to stay structural (`OperandEncryptionClient`) rather than + * naming `EncryptionClient` nominally. + * + * Every operand is now encrypted with `encryptQuery` (#622), so the operand + * client contract is `encryptQuery` in both its single and batch forms; the + * doubles model that. Lives in a `*.test-d.ts` so it is inside the existing + * typecheck scope without dragging the loose-typed runtime suites in. */ describe('createEncryptionOperators - client parameter (M1)', () => { - type V3Client = EncryptionClientFor + const users = encryptedTable('users', { + email: types.TextSearch('email'), + age: types.IntegerOrd('age'), + }) // A query operation resolving `Result` — the surface the factory drives. type QueryOp = { @@ -30,13 +47,15 @@ describe('createEncryptionOperators - client parameter (M1)', () => { then: PromiseLike>['then'] } - it('accepts the client EncryptionV3 returns with no cast', () => { - expectTypeOf(createEncryptionOperators).toBeCallableWith({} as V3Client) + it('accepts the defaulted EncryptionClient', () => { + expectTypeOf(createEncryptionOperators).toBeCallableWith( + {} as EncryptionClient, + ) }) - it('still accepts the nominal EncryptionClient', () => { + it('accepts a client built for a concrete schema tuple', () => { expectTypeOf(createEncryptionOperators).toBeCallableWith( - {} as EncryptionClient, + {} as EncryptionClient, ) }) diff --git a/packages/stack-drizzle/integration/adapter.ts b/packages/stack-drizzle/integration/adapter.ts index 635572de3..d08760e67 100644 --- a/packages/stack-drizzle/integration/adapter.ts +++ b/packages/stack-drizzle/integration/adapter.ts @@ -1,7 +1,7 @@ import { type AnyV3Table, - type EncryptionClientFor, - EncryptionV3, + Encryption, + type EncryptionClient, } from '@cipherstash/stack/v3' import { databaseUrl, @@ -61,7 +61,7 @@ type AnyTable = any export function makeDrizzleAdapter(): IntegrationAdapter { let sqlClient: postgres.Sql let db: ReturnType - let client: EncryptionClientFor + let client: EncryptionClient let ops: ReturnType let table: AnyTable let schema: ReturnType @@ -161,7 +161,7 @@ export function makeDrizzleAdapter(): IntegrationAdapter { // The client must know this table's schema to encrypt for it. Rebuilt per // family, since each family has its own columns. - client = await EncryptionV3({ schemas: [schema as never] }) + client = await Encryption({ schemas: [schema as never] }) ops = createEncryptionOperators(client) }, diff --git a/packages/stack-drizzle/integration/json-adapter.ts b/packages/stack-drizzle/integration/json-adapter.ts index 44743a1d3..ca05eef71 100644 --- a/packages/stack-drizzle/integration/json-adapter.ts +++ b/packages/stack-drizzle/integration/json-adapter.ts @@ -1,7 +1,7 @@ import { type AnyV3Table, - type EncryptionClientFor, - EncryptionV3, + Encryption, + type EncryptionClient, } from '@cipherstash/stack/v3' import { databaseUrl, @@ -29,7 +29,7 @@ export function makeDrizzleJsonAdapter(): JsonIntegrationAdapter { let db: ReturnType let tableName: string let table: AnyTable - let client: EncryptionClientFor + let client: EncryptionClient let ops: ReturnType const rowsFor = async ( @@ -76,7 +76,7 @@ export function makeDrizzleJsonAdapter(): JsonIntegrationAdapter { document: types.Json('document'), }) const schema = extractEncryptionSchema(table) - client = await EncryptionV3({ schemas: [schema] }) + client = await Encryption({ schemas: [schema] }) ops = createEncryptionOperators(client) await sqlClient.unsafe(`DROP TABLE IF EXISTS ${tableName}`) diff --git a/packages/stack-drizzle/integration/lock-context.integration.test.ts b/packages/stack-drizzle/integration/lock-context.integration.test.ts index fd5dbd568..867809d3a 100644 --- a/packages/stack-drizzle/integration/lock-context.integration.test.ts +++ b/packages/stack-drizzle/integration/lock-context.integration.test.ts @@ -33,7 +33,7 @@ * control. */ import { type AuthFailure, OidcFederationStrategy } from '@cipherstash/stack' -import { type EncryptionClientFor, EncryptionV3 } from '@cipherstash/stack/v3' +import { Encryption, type EncryptionClient } from '@cipherstash/stack/v3' import { databaseUrl, unwrapResult, V3_MATRIX } from '@cipherstash/test-kit' import { clerkJwtProvider } from '@cipherstash/test-kit/integration-clerk' import { and, asc as drizzleAsc, eq as drizzleEq, type SQL } from 'drizzle-orm' @@ -71,7 +71,7 @@ const schema = extractEncryptionSchema(secretTable) type SelectRow = { rowKey: string } -let client: EncryptionClientFor +let client: EncryptionClient let ops: ReturnType let db: ReturnType @@ -161,7 +161,7 @@ beforeAll(async () => { if (federation.failure) { throw new Error(`[federation]: ${authFailureMessage(federation.failure)}`) } - client = await EncryptionV3({ + client = await Encryption({ schemas: [schema], config: { authStrategy: federation.data }, }) @@ -320,7 +320,7 @@ describe('v3 drizzle operators with lock context (live pg)', () => { `[federation B]: ${authFailureMessage(federationB.failure)}`, ) } - const clientB = await EncryptionV3({ + const clientB = await Encryption({ schemas: [schema], config: { authStrategy: federationB.data }, }) diff --git a/packages/stack-drizzle/integration/null-persistence.integration.test.ts b/packages/stack-drizzle/integration/null-persistence.integration.test.ts index a9cc7d5d7..76c2bbc46 100644 --- a/packages/stack-drizzle/integration/null-persistence.integration.test.ts +++ b/packages/stack-drizzle/integration/null-persistence.integration.test.ts @@ -13,7 +13,7 @@ * as SQL NULL, and the present cell still decrypts to its plaintext. */ -import { type EncryptionClientFor, EncryptionV3 } from '@cipherstash/stack/v3' +import { Encryption, type EncryptionClient } from '@cipherstash/stack/v3' import { databaseUrl, V3_MATRIX } from '@cipherstash/test-kit' import { and, asc as drizzleAsc, eq as drizzleEq, type SQL } from 'drizzle-orm' import { integer, pgTable, text } from 'drizzle-orm/pg-core' @@ -84,7 +84,7 @@ const schema = extractEncryptionSchema(nullableTable) type SelectRow = { rowKey: string } -let client: EncryptionClientFor +let client: EncryptionClient let ops: ReturnType let db: ReturnType @@ -107,7 +107,7 @@ async function selectRowKeys(condition: SQL): Promise { beforeAll(async () => { // EQL v3 is installed once per run by `global-setup.ts`. - client = await EncryptionV3({ schemas: [schema] }) + client = await Encryption({ schemas: [schema] }) ops = createEncryptionOperators(client) db = drizzle({ client: sqlClient }) diff --git a/packages/stack-drizzle/integration/relational.integration.test.ts b/packages/stack-drizzle/integration/relational.integration.test.ts index 892e73077..98df8335e 100644 --- a/packages/stack-drizzle/integration/relational.integration.test.ts +++ b/packages/stack-drizzle/integration/relational.integration.test.ts @@ -19,7 +19,7 @@ * `stash eql install`. This suite throws rather than skips when unconfigured. */ -import { type EncryptionClientFor, EncryptionV3 } from '@cipherstash/stack/v3' +import { Encryption, type EncryptionClient } from '@cipherstash/stack/v3' import { type DomainSpec, databaseUrl, @@ -136,7 +136,7 @@ type RowKey = (typeof ROWS)[number] type MatrixPlainRow = Record type SelectRow = { rowKey: string } type Db = ReturnType -type Client = EncryptionClientFor +type Client = EncryptionClient type Ops = ReturnType let client: Client let ops: Ops @@ -214,7 +214,7 @@ function assertScopeKeys( } beforeAll(async () => { - client = await EncryptionV3({ schemas: [schema, bigintSchema] }) + client = await Encryption({ schemas: [schema, bigintSchema] }) ops = createEncryptionOperators(client) db = drizzle({ client: sqlClient }) @@ -554,7 +554,7 @@ describe('v3 drizzle — relational, needle guards, pagination', () => { ) }, 30000) - // A real `TypedEncryptionClient` exposes `bulkEncrypt`, so these lists are + // A real `EncryptionClient` exposes `bulkEncrypt`, so these lists are // encrypted in one FFI crossing and the returned terms must line up // index-for-index with the values. Five values also crosses the // MAX_IN_ARRAY_CONCURRENCY=4 boundary of the single-encrypt fallback that a diff --git a/packages/stack-drizzle/src/operators.ts b/packages/stack-drizzle/src/operators.ts index 4f6df813d..4a6ed5dfd 100644 --- a/packages/stack-drizzle/src/operators.ts +++ b/packages/stack-drizzle/src/operators.ts @@ -46,12 +46,19 @@ import { type ComparisonOp, type EqualityOp, v3Dialect } from './sql-dialect.js' /** * The client capability this factory consumes: `encryptQuery`, in both its * single (`value, opts`) and batch (`terms[]`) forms. Declared structurally — - * with maximally-permissive operands — so it is satisfied by the nominal - * `EncryptionClient`, by the `TypedEncryptionClient` that `EncryptionV3` returns - * (whatever its schema tuple), AND by a hand-rolled test double, none needing a - * cast. Typing the parameter to the nominal `TypedEncryptionClient` would - * reject a client built for a narrower schema tuple (it accepts fewer tables than - * `readonly AnyV3Table[]`); the structural surface sidesteps that variance. + * with maximally-permissive operands — so it is satisfied by an + * `EncryptionClient` at ANY schema-tuple instantiation (the defaulted + * `readonly AnyV3Table[]` one and the narrowed one `Encryption({ schemas })` + * returns are mutually assignable, so tuple width costs nothing either way) + * AND by anything else that can only `encryptQuery` — a partial client, an + * adapter, a test double — none needing a cast. + * + * That last group is the reason the parameter is not simply typed + * `client: EncryptionClient`. `EncryptionClient` is a ten-member interface; + * naming it nominally would demand `encryptModel`, `decrypt`, `decryptModel` + * and the rest from every caller, rejecting anything that supplies only the + * capability this factory actually consumes. See `__tests__/operators.test-d.ts`, + * which pins all three shapes. * * Every operand is a QUERY TERM, not a storage envelope: `encryptQuery` mints a * ciphertext-free term (no `c`) carrying all of the column's configured index @@ -181,15 +188,16 @@ type ChainableOperation = { * {@link EncryptionOperatorError} when the column can't answer the operator * (e.g. ordering a non-`ore` column). * - * @param client - anything that can `encryptQuery` — the nominal - * `EncryptionClient` or the `TypedEncryptionClient` from `EncryptionV3` (no - * cast needed). + * @param client - anything that can `encryptQuery`: an `EncryptionClient` at any + * schema-tuple instantiation, including the narrowed one + * `Encryption({ schemas })` returns, or a partial client supplying just that + * capability. No cast needed in either case. * @param defaults - lock context / audit applied to every operand encryption * unless a per-call override is supplied. * * @example * ```typescript - * const ops = createEncryptionOperators(await EncryptionV3({ schemas: [users] })) + * const ops = createEncryptionOperators(await Encryption({ schemas: [users] })) * await db.select().from(users).where(await ops.eq(users.email, 'a@b.com')) * ``` */ diff --git a/packages/stack-supabase/__tests__/column-map-predicate.test.ts b/packages/stack-supabase/__tests__/column-map-predicate.test.ts index 2575e993a..6add989cb 100644 --- a/packages/stack-supabase/__tests__/column-map-predicate.test.ts +++ b/packages/stack-supabase/__tests__/column-map-predicate.test.ts @@ -1,4 +1,3 @@ -import { encryptedColumn } from '@cipherstash/stack/schema' import { describe, expect, it } from 'vitest' import { isV3ColumnLike } from '../src/column-map' @@ -115,12 +114,15 @@ describe('isV3ColumnLike', () => { expect(isV3ColumnLike(builder)).toBe(true) }) - // The case the four-probe design exists for, stated with the real class - // rather than a hand-rolled stub: a v2 column builder genuinely has `build()` - // and `getName()` and genuinely lacks the other two. A two-probe gate would - // accept it and its filter operands would go to PostgREST in the clear. - it('rejects a real EQL v2 column builder', () => { - const v2 = encryptedColumn('email').equality() + // The case the four-probe design exists for: a v2 column builder genuinely + // has `build()` and `getName()` and genuinely lacks the other two. A two-probe + // gate would accept it and its filter operands would go to PostgREST in the + // clear. The stub is hand-rolled because the v2 `EncryptedColumn` class no + // longer exists to import — it was deleted with the v3-only collapse — so + // this pins the STRUCTURE that class had, which is what `isV3ColumnLike` + // discriminates on. + it('rejects the structural shape of a legacy v2 column builder', () => { + const v2 = { getName: () => 'email', build: () => ({}) } // Spelled out so that if `EncryptedColumn` ever grows one of these, the // failure names the cause rather than just reporting `true !== false`. diff --git a/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts b/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts index 185700364..b35434041 100644 --- a/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts +++ b/packages/stack-supabase/__tests__/supabase-schema-builder.test.ts @@ -1,8 +1,4 @@ import { encryptedTable, types } from '@cipherstash/stack/eql/v3' -import { - encryptedColumn, - encryptedTable as v2EncryptedTable, -} from '@cipherstash/stack/schema' import { describe, expect, it } from 'vitest' import { ColumnMap } from '../src/column-map' import type { IntrospectionResult } from '../src/introspect' @@ -314,9 +310,11 @@ describe('ColumnMap recognises v3 columns structurally, not by class identity', // // The column-level probe above cannot catch this: it runs later, and by // then the constructor has already crashed. - const v2Table = v2EncryptedTable('users', { - email: encryptedColumn('email').equality(), - }) + const v2Table = { + tableName: 'users', + columnBuilders: { email: { getName: () => 'email', build: () => ({}) } }, + build: () => ({ tableName: 'users', columns: {} }), + } expect(() => new ColumnMap('users', v2Table as never, null)).toThrow( /\[supabase v3\]: table "users" is an EQL v2 table/, diff --git a/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts b/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts index eac574e76..148ac7d38 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts @@ -1,8 +1,4 @@ import { encryptedTable, types } from '@cipherstash/stack/eql/v3' -import { - encryptedColumn, - encryptedTable as v2EncryptedTable, -} from '@cipherstash/stack/schema' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SupabaseClientLike } from '../src/index.js' import { encryptedSupabaseV3 } from '../src/index.js' @@ -139,9 +135,11 @@ describe('encryptedSupabaseV3 factory', () => { // check and dies deeper in — previously at `verify.ts`, as // `builder.getEqlType is not a function`, which names an internal method // rather than the version mismatch. - const users = v2EncryptedTable('users', { - email: encryptedColumn('email').equality(), - }) + const users = { + tableName: 'users', + columnBuilders: { email: { getName: () => 'email', build: () => ({}) } }, + build: () => ({ tableName: 'users', columns: {} }), + } await expect( encryptedSupabaseV3(fakeClient, { @@ -316,26 +314,23 @@ describe('encryptedSupabaseV3 factory', () => { }) }) - // `eqlVersion` is forced, not defaulted. Without the force a caller's - // `eqlVersion: 2` would now make `Encryption` throw at setup, against the - // all-v3 schema set introspection synthesizes. - it('forces eqlVersion 3 over a caller-supplied config, passing other keys through', async () => { + it('passes client configuration through without adding eqlVersion', async () => { await encryptedSupabaseV3(fakeClient, { databaseUrl: 'postgres://x', - config: { eqlVersion: 2, workspaceCrn: 'crn:test' } as never, + config: { workspaceCrn: 'crn:test' }, }) const arg = encryptionMock.mock.calls[0][0] as { config: Record } - expect(arg.config.eqlVersion).toBe(3) + expect(arg.config).not.toHaveProperty('eqlVersion') expect(arg.config.workspaceCrn).toBe('crn:test') }) - it('defaults config to { eqlVersion: 3 } when none is supplied', async () => { + it('leaves config undefined when none is supplied', async () => { await encryptedSupabaseV3(fakeClient, { databaseUrl: 'postgres://x' }) const arg = encryptionMock.mock.calls[0][0] as { config: unknown } - expect(arg.config).toEqual({ eqlVersion: 3 }) + expect(arg.config).toBeUndefined() }) }) diff --git a/packages/stack-supabase/__tests__/supabase-v3.test-d.ts b/packages/stack-supabase/__tests__/supabase-v3.test-d.ts index f1b819230..c79ff2044 100644 --- a/packages/stack-supabase/__tests__/supabase-v3.test-d.ts +++ b/packages/stack-supabase/__tests__/supabase-v3.test-d.ts @@ -3,10 +3,6 @@ import { type InferPlaintext, types, } from '@cipherstash/stack/eql/v3' -import { - encryptedColumn, - encryptedTable as v2EncryptedTable, -} from '@cipherstash/stack/schema' import { describe, expectTypeOf, it } from 'vitest' import { type EncryptedQueryBuilder, @@ -276,9 +272,19 @@ describe('encryptedSupabaseV3 typed surface (with schemas)', () => { }) it('rejects a v2 table in schemas', async () => { - const v2Table = v2EncryptedTable('users', { - email: encryptedColumn('email').equality(), - }) + // Shaped like a real v2 `EncryptedTable`: `tableName`, `columnBuilders` + // (each a `{ getName, build }` v2 column builder), and `build()`. The v2 + // builders were deleted with the v3-only collapse, so the shape has to be + // hand-rolled — but it must stay v2-SHAPED, otherwise this only proves that + // an arbitrary object is rejected and would keep passing even if v2-table + // rejection broke. + const v2Table = { + tableName: 'users', + columnBuilders: { + email: { getName: () => 'email', build: () => ({}) }, + }, + build: () => ({ tableName: 'users', columns: {} }), + } // The directive sits on the call, not the property: no overload accepts a // v2 table, so TypeScript reports the failure at the call expression. // @ts-expect-error — schemas only accepts v3 tables diff --git a/packages/stack-supabase/src/column-map.ts b/packages/stack-supabase/src/column-map.ts index c3974b0fa..ab0258ace 100644 --- a/packages/stack-supabase/src/column-map.ts +++ b/packages/stack-supabase/src/column-map.ts @@ -1,7 +1,6 @@ import { hasBuildColumnKeyMap } from '@cipherstash/stack/adapter-kit' import type { AnyV3Table } from '@cipherstash/stack/eql/v3' import type { ColumnSchema } from '@cipherstash/stack/schema' -import type { BuildableQueryColumn } from '@cipherstash/stack/types' import type { DbName } from './types' /** @@ -287,13 +286,7 @@ export class ColumnMap { } /** The encrypted builders as the term collector's column lookup. */ - queryColumnMap(): Record { - // `V3ColumnLike` omits `isQueryable(): true`, which `BuildableV3QueryableColumn` - // requires — and `v3Columns` intentionally holds storage-only columns, for which - // `isQueryable()` is `false`. The collector consults `getQueryCapabilities()` - // before using an entry (`query-encrypt.ts:513`), so the widening is safe; - // narrowing the type would mean narrowing the map. - // biome-ignore lint/plugin: storage-only v3 columns lack `isQueryable(): true`; widening is safe (see above). - return this.v3Columns as unknown as Record + queryColumnMap(): Record { + return this.v3Columns } } diff --git a/packages/stack-supabase/src/index.ts b/packages/stack-supabase/src/index.ts index ea7932003..9ba8301f7 100644 --- a/packages/stack-supabase/src/index.ts +++ b/packages/stack-supabase/src/index.ts @@ -232,7 +232,7 @@ export async function encryptedSupabase( schemas: encryptionSchemas as unknown as Parameters< typeof Encryption >[0]['schemas'], - config: { ...options.config, eqlVersion: 3 }, + config: options.config, }) // 6. Return the instance. `from` resolves the introspected/merged table and diff --git a/packages/stack-supabase/src/query-builder.ts b/packages/stack-supabase/src/query-builder.ts index 7bfbeaa57..b98d58478 100644 --- a/packages/stack-supabase/src/query-builder.ts +++ b/packages/stack-supabase/src/query-builder.ts @@ -82,10 +82,10 @@ const warnedLikeDelegation = new Set() * passthroughs. Decrypt v2 data with the core `@cipherstash/stack` client. * * The pipeline is split across sibling modules — `./column-map` (name and - * capability resolution), `./query-encrypt` (mutation data and filter terms), - * `./query-dbspace` (property → DB space), `./query-filters` (operand - * substitution), `./query-results` (decryption) — and orchestrated by - * {@link execute} below. + * capability resolution), `./query-dbspace` (property → DB space), + * `./query-terms` (which operands need encrypting), `./query-encrypt` + * (mutation data and filter terms), `./query-filters` (operand substitution), + * `./query-results` (decryption) — and orchestrated by {@link execute} below. */ export class EncryptedQueryBuilderImpl< T extends object = Record, diff --git a/packages/stack-supabase/src/query-encrypt.ts b/packages/stack-supabase/src/query-encrypt.ts index 6d617ae10..ad280b7cd 100644 --- a/packages/stack-supabase/src/query-encrypt.ts +++ b/packages/stack-supabase/src/query-encrypt.ts @@ -1,4 +1,3 @@ -import type { JsPlaintext } from '@cipherstash/protect-ffi' import type { AuditConfig } from '@cipherstash/stack/adapter-kit' import { logger, matchNeedleError } from '@cipherstash/stack/adapter-kit' import type { EncryptionClient } from '@cipherstash/stack/encryption' @@ -11,16 +10,12 @@ import type { LockContextInput } from '@cipherstash/stack/identity' import type { Encrypted, EncryptedQueryResult, - QueryTypeName, ScalarQueryTerm, } from '@cipherstash/stack/types' import type { ColumnMap, V3ColumnLike } from './column-map' -import { - isEncryptableTerm, - isEncryptedColumn, - mapFilterOpToQueryType, -} from './helpers' -import type { DbQuerySpace, FilterOp } from './types' +import type { CollectedQueryTerm, TermMapping } from './query-terms' +import { collectQueryTerms } from './query-terms' +import type { DbQuerySpace } from './types' export class EncryptionFailedError extends Error { public encryptionError: EncryptionError @@ -32,24 +27,6 @@ export class EncryptionFailedError extends Error { } } -export type TermMapping = - | { source: 'filter'; filterIndex: number; inIndex?: number } - | { source: 'match'; matchIndex: number; column: string } - | { source: 'not'; notIndex: number; inIndex?: number } - | { source: 'raw'; rawIndex: number; inIndex?: number } - | { - source: 'or-string' - orIndex: number - conditionIndex: number - inIndex?: number - } - | { - source: 'or-structured' - orIndex: number - conditionIndex: number - inIndex?: number - } - export type EncryptedFilterState = { // `EncryptedQueryResult[]`, not `unknown[]` — `encryptCollectedTerms` returns // that type, and typing the field to match is what lets the restored envelope @@ -75,6 +52,26 @@ export type EncryptionContext = { queryDomainsRequired: boolean } +type DynamicEncryptionClient = { + encrypt( + value: ScalarQueryTerm['value'], + options: { column: V3ColumnLike; table: AnyV3Table }, + ): ReturnType + encryptModel( + model: Record, + table: AnyV3Table, + ): ReturnType + bulkEncryptModels( + models: Record[], + table: AnyV3Table, + ): ReturnType +} + +/** Dynamic adapter boundary for schemas discovered at runtime. */ +export const dynamicEncryptionClient = ( + client: EncryptionClient, +): DynamicEncryptionClient => client as DynamicEncryptionClient + /** * Apply the builder's lock context and audit config to a pending operation. * @@ -160,70 +157,6 @@ export function assertJsonContainmentOperand( } } -/** - * Resolve a raw `.filter()` operator to the capability it exercises. A - * supported v3 operand is a full storage envelope, so `queryType` never - * selects a narrowing — it only tells {@link assertTermQueryable} which - * capability to demand of the column. - * - * Unknown operators throw rather than silently defaulting to equality, which - * would encrypt a term the column may not even be able to compare. - */ -export function queryTypeForRawOp(operator: string): QueryTypeName { - switch (operator) { - case 'cs': - return 'freeTextSearch' - case 'gt': - case 'gte': - case 'lt': - case 'lte': - return 'orderAndRange' - case 'eq': - case 'neq': - case 'in': - case 'is': - return 'equality' - default: - throw new Error( - `[supabase v3]: unsupported raw filter operator "${operator}" on an encrypted column`, - ) - } -} - -/** - * The CipherStash query type for an `.or()` condition's operator on an - * encrypted column. String-form conditions carry raw PostgREST operators - * (`cs`), which are not {@link FilterOp}s. - */ -export function queryTypeForOrOp(op: FilterOp): QueryTypeName { - if (op === 'matches') return 'freeTextSearch' - // Structured conditions may carry the `contains` METHOD spelling (the wire - // token becomes `cs` in rebuildOrString). It maps to the same capability - // gate as `cs`; on a JSON column the term resolver then re-types it to - // searchableJson and validates the operand. selectorNe's IS-NULL-inclusive - // or-form relies on this arm. - if (op === 'contains') return 'freeTextSearch' - return queryTypeForRawOp(op) -} - -/** A nullish encrypted search operand is never a SQL-NULL predicate. Skipping - * encryption would put the raw operand on the wire under `cs`, so fail closed - * for every spelling (`matches`, `contains`, raw `cs`, `not`, and `.or()`). */ -function assertEncryptedSearchOperand( - queryType: QueryTypeName, - value: unknown, - column: string, -): void { - if ( - value == null && - (queryType === 'freeTextSearch' || queryType === 'searchableJson') - ) { - throw new Error( - `[supabase v3]: encrypted search on column "${column}" requires a non-null operand; null and undefined cannot be sent through the plaintext PostgREST filter path.`, - ) - } -} - function encryptionFailure( tableName: string, message: string, @@ -250,202 +183,7 @@ export async function encryptFilterValues( dbSpace: DbQuerySpace, ctx: EncryptionContext, ): Promise { - // Collect all terms that need encryption - const terms: ScalarQueryTerm[] = [] - const termMap: TermMapping[] = [] - - const tableColumns = ctx.columns.queryColumnMap() - const encryptedColumnNames = ctx.columns.encryptedColumnNames - - const pushTerm = ( - value: JsPlaintext, - column: ScalarQueryTerm['column'], - queryType: QueryTypeName, - mapping: TermMapping, - ) => { - terms.push({ - value, - column, - table: ctx.table, - queryType, - }) - termMap.push(mapping) - } - - /** - * Collect one term per element of an `in`-list operand. - * - * Element-wise is the only correct encoding: encrypting the array as ONE - * value collapses `(a,b)` into a single ciphertext that matches nothing. A - * null element is SQL NULL and passes through unencrypted; the applier - * restores it by index, which is why the mapping carries `inIndex`. - * - * Shared by the regular-`in`, `not(…,'in',…)` and or-condition paths. They - * drifted apart once already — the `not` path went unfixed while the other - * two encrypted element-wise — so they are kept in lockstep here rather than - * spelled out three times. - */ - const collectInListTerms = ( - op: FilterOp, - values: readonly unknown[], - column: ScalarQueryTerm['column'], - queryType: QueryTypeName, - mappingFor: (inIndex: number) => TermMapping, - ) => { - for (let j = 0; j < values.length; j++) { - if (!isEncryptableTerm(op, values[j])) continue - pushTerm(values[j] as JsPlaintext, column, queryType, mappingFor(j)) - } - } - - // Regular filters - for (let i = 0; i < dbSpace.filters.length; i++) { - const f = dbSpace.filters[i] - if (!isEncryptedColumn(f.column, encryptedColumnNames)) continue - - const column = tableColumns[f.column] - if (!column) continue - const queryType = mapFilterOpToQueryType(f.op) - assertEncryptedSearchOperand(queryType, f.value, f.column) - - if (f.op === 'in' && Array.isArray(f.value)) { - collectInListTerms(f.op, f.value, column, queryType, (inIndex) => ({ - source: 'filter', - filterIndex: i, - inIndex, - })) - } else if (!isEncryptableTerm(f.op, f.value)) { - // `is` predicate or null operand — forwarded unencrypted. - } else { - pushTerm(f.value as JsPlaintext, column, queryType, { - source: 'filter', - filterIndex: i, - }) - } - } - - // Match filters - for (let i = 0; i < dbSpace.matchFilters.length; i++) { - const mf = dbSpace.matchFilters[i] - for (const { column: colName, value } of mf.entries) { - if (!isEncryptedColumn(colName, encryptedColumnNames)) continue - // `match` carries no operator; equality is implied. - if (!isEncryptableTerm('eq', value)) continue - const column = tableColumns[colName] - if (!column) continue - - pushTerm(value as JsPlaintext, column, 'equality', { - source: 'match', - matchIndex: i, - column: colName, - }) - } - } - - // Not filters - for (let i = 0; i < dbSpace.notFilters.length; i++) { - const nf = dbSpace.notFilters[i] - if (!isEncryptedColumn(nf.column, encryptedColumnNames)) continue - const column = tableColumns[nf.column] - if (!column) continue - const queryType = mapFilterOpToQueryType(nf.op) - assertEncryptedSearchOperand(queryType, nf.value, nf.column) - if (!isEncryptableTerm(nf.op, nf.value)) continue - - if (nf.op === 'in') { - // A PostgREST list literal (`'(a,b)'`) cannot be encrypted element-wise, - // and encrypting it whole matches nothing. Refuse it rather than emit a - // filter that silently returns no rows. - if (!Array.isArray(nf.value)) { - throw new Error( - `not("${nf.column}", "in", …) on an encrypted column requires an array of values, ` + - `not a PostgREST list literal — each element must be encrypted separately`, - ) - } - collectInListTerms(nf.op, nf.value, column, queryType, (inIndex) => ({ - source: 'not', - notIndex: i, - inIndex, - })) - continue - } - - pushTerm(nf.value as JsPlaintext, column, queryType, { - source: 'not', - notIndex: i, - }) - } - - // Or filters — conditions were parsed once, in `toDbSpace`. The string and - // structured forms differ only in their `source` tag; the encryption rules, - // including the `in`-list split below, are identical. - for (let i = 0; i < dbSpace.orFilters.length; i++) { - const of_ = dbSpace.orFilters[i] - const source = of_.kind === 'string' ? 'or-string' : 'or-structured' - - for (let j = 0; j < of_.conditions.length; j++) { - const cond = of_.conditions[j] - if (!isEncryptedColumn(cond.column, encryptedColumnNames)) continue - const column = tableColumns[cond.column] - if (!column) continue - - // `queryTypeForOrOp`, not `mapFilterOpToQueryType`: an or-condition may - // carry a raw PostgREST operator (`cs`), which is not a `FilterOp`. - const queryType = queryTypeForOrOp(cond.op) - assertEncryptedSearchOperand(queryType, cond.value, cond.column) - const mappingFor = (inIndex?: number): TermMapping => ({ - source, - orIndex: i, - conditionIndex: j, - inIndex, - }) - - if (cond.op === 'in' && Array.isArray(cond.value)) { - collectInListTerms(cond.op, cond.value, column, queryType, mappingFor) - continue - } - - if (!isEncryptableTerm(cond.op, cond.value)) continue - pushTerm(cond.value as JsPlaintext, column, queryType, mappingFor()) - } - } - - // Raw filters - for (let i = 0; i < dbSpace.rawFilters.length; i++) { - const rf = dbSpace.rawFilters[i] - if (!isEncryptedColumn(rf.column, encryptedColumnNames)) continue - const column = tableColumns[rf.column] - if (!column) continue - const queryType = queryTypeForRawOp(rf.operator) - assertEncryptedSearchOperand(queryType, rf.value, rf.column) - - if (rf.operator === 'in') { - // Same contract as the `not(…, 'in', …)` path: a PostgREST list literal - // (`'("a","b")'`) cannot be encrypted element-wise, and encrypting it - // whole matches nothing. Refuse it rather than emit a filter that - // silently returns no rows. - if (!Array.isArray(rf.value)) { - throw new Error( - `filter("${rf.column}", "in", …) on an encrypted column requires an array of values, ` + - `not a PostgREST list literal — each element must be encrypted separately`, - ) - } - collectInListTerms('in', rf.value, column, queryType, (inIndex) => ({ - source: 'raw', - rawIndex: i, - inIndex, - })) - continue - } - - if (!isEncryptableTerm(rf.operator, rf.value)) continue - - pushTerm(rf.value as JsPlaintext, column, queryType, { - source: 'raw', - rawIndex: i, - }) - } - + const { terms, termMap } = collectQueryTerms(dbSpace, ctx) if (terms.length === 0) { return { encryptedValues: [], termMap: [] } } @@ -473,7 +211,7 @@ export async function encryptFilterValues( * must group a multi-column term array and preserve positions. */ async function encryptCollectedTerms( - terms: ScalarQueryTerm[], + terms: CollectedQueryTerm[], ctx: EncryptionContext, ): Promise { const groups = new Map< @@ -519,12 +257,12 @@ async function encryptCollectedTerms( * path can place ciphertext in a GET URL. * * Exported for direct testing: no public call path can produce an unsupported - * `queryType` (`mapFilterOpToQueryType`, {@link queryTypeForRawOp} and - * {@link queryTypeForOrOp} are exhaustive), so that backstop is only reachable - * by calling this with a hand-built term. + * `queryType` (`mapFilterOpToQueryType`, and `./query-terms`'s + * `queryTypeForRawOp` / `queryTypeForOrOp`, are exhaustive), so that backstop + * is only reachable by calling this with a hand-built term. */ export function assertTermQueryable( - term: ScalarQueryTerm, + term: CollectedQueryTerm, ctx: EncryptionContext, ): V3ColumnLike { const column = term.column as unknown as V3ColumnLike @@ -659,10 +397,11 @@ async function encryptGroupPerTerm( values: ScalarQueryTerm['value'][], ctx: EncryptionContext, ): Promise { + const client = dynamicEncryptionClient(ctx.encryptionClient) return Promise.all( values.map(async (value) => { const result = await withOpContext( - ctx.encryptionClient.encrypt(value, { + client.encrypt(value, { column, table: ctx.table, }), diff --git a/packages/stack-supabase/src/query-mutation.ts b/packages/stack-supabase/src/query-mutation.ts index 154f3244a..77da9902b 100644 --- a/packages/stack-supabase/src/query-mutation.ts +++ b/packages/stack-supabase/src/query-mutation.ts @@ -1,6 +1,7 @@ import { logger } from '@cipherstash/stack/adapter-kit' import type { ColumnMap } from './column-map' import { + dynamicEncryptionClient, type EncryptionContext, EncryptionFailedError, withOpContext, @@ -36,11 +37,12 @@ export async function encryptMutationData( if (mutation.kind === 'delete') return null const data = mutation.data + const client = dynamicEncryptionClient(ctx.encryptionClient) if (Array.isArray(data)) { // Bulk encrypt const result = await withOpContext( - ctx.encryptionClient.bulkEncryptModels(data, ctx.table), + client.bulkEncryptModels(data, ctx.table), ctx, ) if (result.failure) { @@ -60,10 +62,7 @@ export async function encryptMutationData( } // Single model - const result = await withOpContext( - ctx.encryptionClient.encryptModel(data, ctx.table), - ctx, - ) + const result = await withOpContext(client.encryptModel(data, ctx.table), ctx) if (result.failure) { logger.error( `Supabase: failed to encrypt model for table "${ctx.tableName}"`, diff --git a/packages/stack-supabase/src/query-terms.ts b/packages/stack-supabase/src/query-terms.ts new file mode 100644 index 000000000..31b3a4965 --- /dev/null +++ b/packages/stack-supabase/src/query-terms.ts @@ -0,0 +1,312 @@ +import type { JsPlaintext } from '@cipherstash/protect-ffi' +import type { AnyV3Table } from '@cipherstash/stack/eql/v3' +import type { QueryTypeName, ScalarQueryTerm } from '@cipherstash/stack/types' +import type { V3ColumnLike } from './column-map' +import { + isEncryptableTerm, + isEncryptedColumn, + mapFilterOpToQueryType, +} from './helpers' +import type { EncryptionContext } from './query-encrypt' +import type { DbQuerySpace, FilterOp } from './types' + +export type TermMapping = + | { source: 'filter'; filterIndex: number; inIndex?: number } + | { source: 'match'; matchIndex: number; column: string } + | { source: 'not'; notIndex: number; inIndex?: number } + | { source: 'raw'; rawIndex: number; inIndex?: number } + | { + source: 'or-string' + orIndex: number + conditionIndex: number + inIndex?: number + } + | { + source: 'or-structured' + orIndex: number + conditionIndex: number + inIndex?: number + } + +export type CollectedQueryTerm = { + value: ScalarQueryTerm['value'] + column: V3ColumnLike + table: AnyV3Table + queryType?: QueryTypeName + returnType?: ScalarQueryTerm['returnType'] +} + +/** + * Resolve a raw `.filter()` operator to the capability it exercises. A + * supported v3 operand is a full storage envelope, so `queryType` never + * selects a narrowing — it only tells `assertTermQueryable` which capability + * to demand of the column. + * + * Unknown operators throw rather than silently defaulting to equality, which + * would encrypt a term the column may not even be able to compare. + */ +export function queryTypeForRawOp(operator: string): QueryTypeName { + switch (operator) { + case 'cs': + return 'freeTextSearch' + case 'gt': + case 'gte': + case 'lt': + case 'lte': + return 'orderAndRange' + case 'eq': + case 'neq': + case 'in': + case 'is': + return 'equality' + default: + throw new Error( + `[supabase v3]: unsupported raw filter operator "${operator}" on an encrypted column`, + ) + } +} + +/** + * The CipherStash query type for an `.or()` condition's operator on an + * encrypted column. String-form conditions carry raw PostgREST operators + * (`cs`), which are not {@link FilterOp}s. + */ +export function queryTypeForOrOp(op: FilterOp): QueryTypeName { + if (op === 'matches') return 'freeTextSearch' + // Structured conditions may carry the `contains` METHOD spelling (the wire + // token becomes `cs` in rebuildOrString). It maps to the same capability + // gate as `cs`; on a JSON column the term resolver then re-types it to + // searchableJson and validates the operand. selectorNe's IS-NULL-inclusive + // or-form relies on this arm. + if (op === 'contains') return 'freeTextSearch' + return queryTypeForRawOp(op) +} + +/** A nullish encrypted search operand is never a SQL-NULL predicate. Skipping + * encryption would put the raw operand on the wire under `cs`, so fail closed + * for every spelling (`matches`, `contains`, raw `cs`, `not`, and `.or()`). */ +function assertEncryptedSearchOperand( + queryType: QueryTypeName, + value: unknown, + column: string, +): void { + if ( + value == null && + (queryType === 'freeTextSearch' || queryType === 'searchableJson') + ) { + throw new Error( + `[supabase v3]: encrypted search on column "${column}" requires a non-null operand; null and undefined cannot be sent through the plaintext PostgREST filter path.`, + ) + } +} + +/** + * Walk a `DbQuerySpace` and collect every operand that must be encrypted, + * paired with the mapping that puts each result back where it came from. + * + * Pure and synchronous: no FFI crossing happens here. Capability validation is + * deliberately NOT done on this pass — `assertTermQueryable` is the single + * boundary for that, so every spelling collected below is checked identically + * rather than once per loop. + */ +export function collectQueryTerms( + dbSpace: DbQuerySpace, + ctx: Pick, +): { terms: CollectedQueryTerm[]; termMap: TermMapping[] } { + const terms: CollectedQueryTerm[] = [] + const termMap: TermMapping[] = [] + + const tableColumns = ctx.columns.queryColumnMap() + const encryptedColumnNames = ctx.columns.encryptedColumnNames + + const pushTerm = ( + value: JsPlaintext, + column: V3ColumnLike, + queryType: QueryTypeName, + mapping: TermMapping, + ) => { + terms.push({ + value, + column, + table: ctx.table, + queryType, + }) + termMap.push(mapping) + } + + /** + * Collect one term per element of an `in`-list operand. + * + * Element-wise is the only correct encoding: encrypting the array as ONE + * value collapses `(a,b)` into a single ciphertext that matches nothing. A + * null element is SQL NULL and passes through unencrypted; the applier + * restores it by index, which is why the mapping carries `inIndex`. + * + * Shared by the regular-`in`, `not(…,'in',…)` and or-condition paths. They + * drifted apart once already — the `not` path went unfixed while the other + * two encrypted element-wise — so they are kept in lockstep here rather than + * spelled out three times. + */ + const collectInListTerms = ( + op: FilterOp, + values: readonly unknown[], + column: V3ColumnLike, + queryType: QueryTypeName, + mappingFor: (inIndex: number) => TermMapping, + ) => { + for (let j = 0; j < values.length; j++) { + if (!isEncryptableTerm(op, values[j])) continue + pushTerm(values[j] as JsPlaintext, column, queryType, mappingFor(j)) + } + } + + // Regular filters + for (let i = 0; i < dbSpace.filters.length; i++) { + const f = dbSpace.filters[i] + if (!isEncryptedColumn(f.column, encryptedColumnNames)) continue + + const column = tableColumns[f.column] + if (!column) continue + const queryType = mapFilterOpToQueryType(f.op) + assertEncryptedSearchOperand(queryType, f.value, f.column) + + if (f.op === 'in' && Array.isArray(f.value)) { + collectInListTerms(f.op, f.value, column, queryType, (inIndex) => ({ + source: 'filter', + filterIndex: i, + inIndex, + })) + } else if (!isEncryptableTerm(f.op, f.value)) { + // `is` predicate or null operand — forwarded unencrypted. + } else { + pushTerm(f.value as JsPlaintext, column, queryType, { + source: 'filter', + filterIndex: i, + }) + } + } + + // Match filters + for (let i = 0; i < dbSpace.matchFilters.length; i++) { + const mf = dbSpace.matchFilters[i] + for (const { column: colName, value } of mf.entries) { + if (!isEncryptedColumn(colName, encryptedColumnNames)) continue + // `match` carries no operator; equality is implied. + if (!isEncryptableTerm('eq', value)) continue + const column = tableColumns[colName] + if (!column) continue + + pushTerm(value as JsPlaintext, column, 'equality', { + source: 'match', + matchIndex: i, + column: colName, + }) + } + } + + // Not filters + for (let i = 0; i < dbSpace.notFilters.length; i++) { + const nf = dbSpace.notFilters[i] + if (!isEncryptedColumn(nf.column, encryptedColumnNames)) continue + const column = tableColumns[nf.column] + if (!column) continue + const queryType = mapFilterOpToQueryType(nf.op) + assertEncryptedSearchOperand(queryType, nf.value, nf.column) + if (!isEncryptableTerm(nf.op, nf.value)) continue + + if (nf.op === 'in') { + // A PostgREST list literal (`'(a,b)'`) cannot be encrypted element-wise, + // and encrypting it whole matches nothing. Refuse it rather than emit a + // filter that silently returns no rows. + if (!Array.isArray(nf.value)) { + throw new Error( + `not("${nf.column}", "in", …) on an encrypted column requires an array of values, ` + + `not a PostgREST list literal — each element must be encrypted separately`, + ) + } + collectInListTerms(nf.op, nf.value, column, queryType, (inIndex) => ({ + source: 'not', + notIndex: i, + inIndex, + })) + continue + } + + pushTerm(nf.value as JsPlaintext, column, queryType, { + source: 'not', + notIndex: i, + }) + } + + // Or filters — conditions were parsed once, in `toDbSpace`. The string and + // structured forms differ only in their `source` tag; the encryption rules, + // including the `in`-list split below, are identical. + for (let i = 0; i < dbSpace.orFilters.length; i++) { + const of_ = dbSpace.orFilters[i] + const source = of_.kind === 'string' ? 'or-string' : 'or-structured' + + for (let j = 0; j < of_.conditions.length; j++) { + const cond = of_.conditions[j] + if (!isEncryptedColumn(cond.column, encryptedColumnNames)) continue + const column = tableColumns[cond.column] + if (!column) continue + + // `queryTypeForOrOp`, not `mapFilterOpToQueryType`: an or-condition may + // carry a raw PostgREST operator (`cs`), which is not a `FilterOp`. + const queryType = queryTypeForOrOp(cond.op) + assertEncryptedSearchOperand(queryType, cond.value, cond.column) + const mappingFor = (inIndex?: number): TermMapping => ({ + source, + orIndex: i, + conditionIndex: j, + inIndex, + }) + + if (cond.op === 'in' && Array.isArray(cond.value)) { + collectInListTerms(cond.op, cond.value, column, queryType, mappingFor) + continue + } + + if (!isEncryptableTerm(cond.op, cond.value)) continue + pushTerm(cond.value as JsPlaintext, column, queryType, mappingFor()) + } + } + + // Raw filters + for (let i = 0; i < dbSpace.rawFilters.length; i++) { + const rf = dbSpace.rawFilters[i] + if (!isEncryptedColumn(rf.column, encryptedColumnNames)) continue + const column = tableColumns[rf.column] + if (!column) continue + const queryType = queryTypeForRawOp(rf.operator) + assertEncryptedSearchOperand(queryType, rf.value, rf.column) + + if (rf.operator === 'in') { + // Same contract as the `not(…, 'in', …)` path: a PostgREST list literal + // (`'("a","b")'`) cannot be encrypted element-wise, and encrypting it + // whole matches nothing. Refuse it rather than emit a filter that + // silently returns no rows. + if (!Array.isArray(rf.value)) { + throw new Error( + `filter("${rf.column}", "in", …) on an encrypted column requires an array of values, ` + + `not a PostgREST list literal — each element must be encrypted separately`, + ) + } + collectInListTerms('in', rf.value, column, queryType, (inIndex) => ({ + source: 'raw', + rawIndex: i, + inIndex, + })) + continue + } + + if (!isEncryptableTerm(rf.operator, rf.value)) continue + + pushTerm(rf.value as JsPlaintext, column, queryType, { + source: 'raw', + rawIndex: i, + }) + } + + return { terms, termMap } +} diff --git a/packages/stack-supabase/src/types.ts b/packages/stack-supabase/src/types.ts index e58cf74c9..3930f6084 100644 --- a/packages/stack-supabase/src/types.ts +++ b/packages/stack-supabase/src/types.ts @@ -29,7 +29,7 @@ export type EncryptedSupabaseOptions< /** Postgres connection string for introspection. Defaults to * `process.env.DATABASE_URL`. */ databaseUrl?: string - /** Passed through to the encryption client (`eqlVersion` is forced to 3). */ + /** Passed through to the v3-only encryption client. */ config?: ClientConfig /** * Optional declared v3 tables, keyed by table name (each record key MUST @@ -817,11 +817,6 @@ export type { LockContext, LockContextInput, } from '@cipherstash/stack/identity' -export type { - EncryptedColumn, - EncryptedTable, - EncryptedTableColumn, -} from '@cipherstash/stack/schema' // --------------------------------------------------------------------------- // Forward declaration for query builder (avoids circular) diff --git a/packages/stack/README.md b/packages/stack/README.md index b74a8caf6..bef0cdd50 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -688,15 +688,35 @@ if (result.failure) { ### `Encryption(config)` - Initialize the typed client ```typescript -function Encryption(config: { - schemas: AnyV3Table[] - config?: ClientConfig -}): Promise +// Overload 1 — non-emptiness in the CONSTRAINT, so code that is itself +// generic over its schemas still compiles. +function Encryption( + config: { schemas: S; config?: ClientConfig }, +): Promise> + +// Overload 2 — any array of v3 tables; non-emptiness is enforced on the +// `schemas` PROPERTY, which keeps `const` inference (and per-column plaintext +// typing) intact on the array-literal path. +function Encryption( + config: { schemas: NonEmptyV3; config?: ClientConfig }, +): Promise> ``` -The wire format is pinned to EQL v3 — you don't set it yourself. `typedClient(client, ...schemas)` (same subpath) wraps an already-built `EncryptionClient` in the typed surface. +(`NonEmptyV3` is an internal helper — it resolves to `S` for any non-empty +array and to `never` for `readonly []`. It is not exported; you never name it.) -### `TypedEncryptionClient` Methods +`schemas` accepts **any non-empty array of v3 tables** — an array literal, a +shared `export const schemas: AnyV3Table[]`, a `ReadonlyArray`, or one built at +runtime by push or spread. A mutable array literal is not required. The returned +client's model and query types are derived from `S`. + +`Encryption({ schemas: [] })` is a compile error. An array *typed* `AnyV3Table[]` +that happens to be empty at runtime compiles and throws on init instead. + +The wire format is pinned to EQL v3 — `config.eqlVersion` is not supported, and +`Encryption()` throws if the field is present at all. + +### `EncryptionClient` Methods Method signatures are derived from your schemas: plaintext arguments are pinned to each column's domain type, query methods only accept queryable columns, and `queryType` is constrained to the column's capabilities. @@ -751,12 +771,11 @@ type UserEncrypted = InferEncrypted | Import Path | Provides | |-------|-----| -| `@cipherstash/stack/v3` | `Encryption` typed client factory (`EncryptionV3` is a `@deprecated` alias), `typedClient`, plus re-exports of the EQL v3 authoring DSL | +| `@cipherstash/stack/v3` | `Encryption`, `EncryptionClient`, and the EQL v3 authoring DSL | | `@cipherstash/stack/eql/v3` | EQL v3 authoring DSL: `encryptedTable`, the `types` namespace, `buildEncryptConfig`, inference types (`InferPlaintext`, `InferEncrypted`, ...) | -| `@cipherstash/stack` | `Encryption` — the single client factory (overloaded: an array of concrete EQL v3 tables yields the typed v3 client) — plus auth strategies | -| `@cipherstash/stack/schema` | Legacy v2 schema builders (see [Legacy: EQL v2](#legacy-eql-v2)) | +| `@cipherstash/stack` | `Encryption` — the v3-only client factory — plus auth strategies | +| `@cipherstash/stack/schema` | Low-level encrypt-config types and validation helpers | | `@cipherstash/stack/identity` | `LockContext` class and identity types | -| `@cipherstash/stack/client` | Client-safe exports (schema builders and types only - no native FFI) | | `@cipherstash/stack/types` | All TypeScript types (`Encrypted`, `Decrypted`, `ClientConfig`, `EncryptionClientConfig`, query types, etc.) | The Drizzle and Supabase integrations are **separate first-party packages** that @@ -769,29 +788,11 @@ depend on `@cipherstash/stack` (they are no longer subpaths of it): ## Legacy: EQL v2 -Before the concrete-domain types above, encrypted columns were declared with -chainable capability builders and stored in a single `eql_v2_encrypted` -composite column type. **v2 is now a read path, not an authoring surface**: -`decrypt` / `decryptModel` still read stored v2 payloads, so existing -deployments keep working, but new work must use EQL v3. Legacy searchable JSON -cannot be emitted by protect-ffi 0.30 and must migrate to v3 `types.Json`: - -- **Client and schema**: `Encryption` from `@cipherstash/stack` with - `encryptedColumn("email").equality().freeTextSearch().orderAndRange()` and - the builders from `@cipherstash/stack/schema`. These are still exported but - `@deprecated` — do not author new schemas with them. v2 and v3 tables cannot - be mixed in one client. -- **Query formatting**: v2 query terms can be rendered as strings with - `returnType: 'composite-literal'` / `'escaped-composite-literal'` for - string-based APIs. -- **Integrations are v3 only.** `encryptedSupabase` is the EQL v3 factory — - there is no v2 Supabase wrapper any more. Likewise - `@cipherstash/stack-drizzle` dropped `encryptedType` and the v2 operators. - Existing v2 columns reached through either adapter are read-only: decrypt - them through `@cipherstash/stack` and migrate to a v3 domain. -- **DynamoDB writes EQL v3 only.** `encryptedDynamoDB` from - `@cipherstash/stack/dynamodb` encrypts with `types.*` v3 tables; its decrypt - methods still accept a v2 table so previously stored items remain readable. +EQL v2 is retained only as native decrypt compatibility. Stored v2 payloads are +recognised automatically by `decrypt`, `decryptModel`, `bulkDecrypt`, and +`bulkDecryptModels`; there is no public v2 schema builder or write-mode flag. +Author all schemas and new writes with EQL v3. DynamoDB legacy reads use a v3 +table descriptor plus `{ storedEqlVersion: 2 }`. Full v2 documentation lives at [cipherstash.com/docs](https://cipherstash.com/docs). @@ -799,9 +800,7 @@ Full v2 documentation lives at [cipherstash.com/docs](https://cipherstash.com/do Method signatures on the encryption client (`encrypt`, `decrypt`, `encryptModel`, ...) and the `Result` pattern (`data` / `failure`) are unchanged. -**Declare tables with the EQL v3 DSL** — the v2 builders below are `@deprecated` -and exist to read and migrate data already written as v2, not to author new -columns. A column's capabilities come from its `types.*` domain rather than +**Declare tables with the EQL v3 DSL.** A column's capabilities come from its `types.*` domain rather than chained tuners: `csColumn("email").equality().freeTextSearch()` becomes `types.TextSearch("email")`. @@ -810,7 +809,6 @@ chained tuners: `csColumn("email").equality().freeTextSearch()` becomes | `protect(config)` | `Encryption(config)` | `@cipherstash/stack` | | `csTable(name, cols)` | `encryptedTable(name, cols)` | `@cipherstash/stack/eql/v3` | | `csColumn(name)` | `types.(name)` (e.g. `types.TextSearch`) | `@cipherstash/stack/eql/v3` | -| `csTable`/`csColumn` for READING legacy v2 data | `encryptedTable` / `encryptedColumn` (`@deprecated`) | `@cipherstash/stack/schema` | | `import { LockContext } from "@cipherstash/protect/identify"` | `import { LockContext } from "@cipherstash/stack/identity"` | `@cipherstash/stack/identity` | | N/A | CLI | `npx stash` | diff --git a/packages/stack/__tests__/audit.test.ts b/packages/stack/__tests__/audit.test.ts index edf2b1df2..c93ec6009 100644 --- a/packages/stack/__tests__/audit.test.ts +++ b/packages/stack/__tests__/audit.test.ts @@ -1,14 +1,14 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' import type { EncryptionClient } from '@/encryption' +import { encryptedTable, types } from '@/eql/v3' import { LockContext } from '@/identity' import { Encryption } from '@/index' -import { encryptedColumn, encryptedTable } from '@/schema' const users = encryptedTable('users', { - auditable: encryptedColumn('auditable'), - email: encryptedColumn('email').freeTextSearch().equality().orderAndRange(), - address: encryptedColumn('address').freeTextSearch(), + auditable: types.Text('auditable'), + email: types.TextSearch('email'), + address: types.TextMatch('address'), }) type User = { diff --git a/packages/stack/__tests__/backward-compat.test.ts b/packages/stack/__tests__/backward-compat.test.ts deleted file mode 100644 index cdf5c930c..000000000 --- a/packages/stack/__tests__/backward-compat.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import 'dotenv/config' -import { beforeAll, describe, expect, it } from 'vitest' -import type { EncryptionClient } from '@/encryption' -import { Encryption } from '@/index' -import { encryptedColumn, encryptedTable } from '@/schema' - -const users = encryptedTable('users', { - email: encryptedColumn('email'), -}) - -describe('k-field discriminator (EQL v2.3)', () => { - let protectClient: EncryptionClient - - beforeAll(async () => { - protectClient = await Encryption({ 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/stack/__tests__/basic-protect.test.ts b/packages/stack/__tests__/basic-protect.test.ts index af687818c..b4f4e3f50 100644 --- a/packages/stack/__tests__/basic-protect.test.ts +++ b/packages/stack/__tests__/basic-protect.test.ts @@ -1,13 +1,13 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' import type { EncryptionClient } from '@/encryption' +import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' -import { encryptedColumn, encryptedTable } from '@/schema' const users = encryptedTable('users', { - email: encryptedColumn('email').freeTextSearch().equality().orderAndRange(), - address: encryptedColumn('address').freeTextSearch(), - json: encryptedColumn('json').dataType('json'), + email: types.TextSearch('email'), + address: types.TextMatch('address'), + json: types.Json('json'), }) let protectClient: EncryptionClient diff --git a/packages/stack/__tests__/bulk-model-hyphen-fields.test.ts b/packages/stack/__tests__/bulk-model-hyphen-fields.test.ts index 9f679d949..6e130f2a4 100644 --- a/packages/stack/__tests__/bulk-model-hyphen-fields.test.ts +++ b/packages/stack/__tests__/bulk-model-hyphen-fields.test.ts @@ -12,8 +12,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { EncryptionClient } from '@/encryption' +import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' -import { encryptedColumn, encryptedTable } from '@/schema' // A protect-ffi-shaped encrypted payload carrying its plaintext in `c` so // the fake decrypt can undo it (and `isEncryptedPayload` detects it). @@ -55,9 +55,9 @@ vi.mock('@cipherstash/protect-ffi', () => ({ // DB column names deliberately contain hyphens — legal in (quoted) Postgres // identifiers and previously corrupted by the naive id split. const users = encryptedTable('users', { - 'some-field': encryptedColumn('some-field').equality(), - 'multi-part-name': encryptedColumn('multi-part-name').equality(), - plainName: encryptedColumn('plainName').equality(), + 'some-field': types.TextEq('some-field'), + 'multi-part-name': types.TextEq('multi-part-name'), + plainName: types.TextEq('plainName'), }) let client: EncryptionClient diff --git a/packages/stack/__tests__/bulk-protect.test.ts b/packages/stack/__tests__/bulk-protect.test.ts index 10d615c7c..ee3745241 100644 --- a/packages/stack/__tests__/bulk-protect.test.ts +++ b/packages/stack/__tests__/bulk-protect.test.ts @@ -1,14 +1,14 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' import type { EncryptionClient } from '@/encryption' +import { encryptedTable, types } from '@/eql/v3' import { LockContext } from '@/identity' import { Encryption } from '@/index' -import { encryptedColumn, encryptedTable } from '@/schema' import type { Encrypted } from '@/types' const users = encryptedTable('users', { - email: encryptedColumn('email').freeTextSearch().equality().orderAndRange(), - address: encryptedColumn('address').freeTextSearch(), + email: types.TextSearch('email'), + address: types.TextMatch('address'), }) type User = { diff --git a/packages/stack/__tests__/decrypt-audit-forwarding.test.ts b/packages/stack/__tests__/decrypt-audit-forwarding.test.ts index f69519e81..dc1df92cc 100644 --- a/packages/stack/__tests__/decrypt-audit-forwarding.test.ts +++ b/packages/stack/__tests__/decrypt-audit-forwarding.test.ts @@ -40,7 +40,7 @@ vi.mock('@cipherstash/protect-ffi', () => ({ })) import * as ffi from '@cipherstash/protect-ffi' -import type { EncryptionClientFor } from '@/encryption/v3' +import type { EncryptionClient } from '@/encryption/v3' // Imported after the mock so the v3 table builder is available; `Encryption` // returns the typed client for an all-v3 schema set. import { encryptedTable, types } from '@/encryption/v3' @@ -67,7 +67,7 @@ const lastDecryptOpts = () => (ffi.decryptBulk as any).mock.calls.at(-1)[1] const lastCiphertextLockContext = () => lastDecryptOpts().ciphertexts[0]?.lockContext -let client: EncryptionClientFor +let client: EncryptionClient let prevWorkspaceCrn: string | undefined beforeEach(async () => { @@ -140,14 +140,29 @@ describe('typed v3 client: audit metadata forwards through decryptModel', () => expect(lastCiphertextLockContext()).toEqual(IDENTITY_CLAIM) }) + /** + * A positional bind followed by a chained one is a COMPILE error: binding + * returns `LockBoundDecryptModelOperation`, which carries no + * `withLockContext` (see `operations/mapped-decrypt.ts`). The runtime throw + * below is the backstop for callers who never see those types — plain + * JavaScript, or anything reaching the client through an `any`. `rebind()` + * models exactly that caller, which is why it casts rather than chaining + * directly; chaining directly would not compile, and a test that does not + * compile is not a test. + * + * Silently keeping the first context would drop the caller's intent and fail + * later at ZeroKMS with an opaque rejection, so the re-bind is rejected at + * the call site instead. + */ + const rebind = (op: unknown, lockContext: { identityClaim: string[] }) => + ( + op as { withLockContext(lc: { identityClaim: string[] }): unknown } + ).withLockContext(lockContext) + it('throws when a second lock context is chained onto an already-bound op', () => { - // The wrapper always exposes `withLockContext`, so a positional bind - // followed by a chained one type-checks. Silently keeping the first would - // drop the caller's intent (and fail later at ZeroKMS with an opaque - // rejection); reject the re-bind at the call site instead. const op = client.decryptModel({ email: enc() }, users, IDENTITY_CLAIM) - expect(() => op.withLockContext({ identityClaim: ['other'] })).toThrow( + expect(() => rebind(op, { identityClaim: ['other'] })).toThrow( /already bound to a lock context/i, ) }) @@ -157,7 +172,7 @@ describe('typed v3 client: audit metadata forwards through decryptModel', () => identityClaim: ['sub'], }) - expect(() => op.withLockContext(IDENTITY_CLAIM)).toThrow( + expect(() => rebind(op, IDENTITY_CLAIM)).toThrow( /already bound to a lock context/i, ) }) diff --git a/packages/stack/__tests__/dynamodb/client-compat.test-d.ts b/packages/stack/__tests__/dynamodb/client-compat.test-d.ts index 715b9b665..5c7b38a64 100644 --- a/packages/stack/__tests__/dynamodb/client-compat.test-d.ts +++ b/packages/stack/__tests__/dynamodb/client-compat.test-d.ts @@ -1,425 +1,39 @@ -/** - * Type-level contract for `encryptedDynamoDB` (#657). - * - * The runtime `*.test.ts` suites are NOT typechecked (see the comment in - * `vitest.config.ts`), which is exactly how an M1-class regression slips - * through: `encryptedDynamoDB` rejecting the `TypedEncryptionClient` that - * `EncryptionV3` returns compiles nowhere and fails nothing, because the live - * suite that would have caught it is only ever *executed*. - * - * So the claims the adapter's own docs make — both client shapes accepted with - * no cast, both wire versions accepted on all four methods, `.audit()` chains, - * awaiting yields a discriminated Result — are locked here, where `tsc` runs. - */ import { describe, expectTypeOf, it } from 'vitest' -import type { EncryptedAttributes, EncryptedDynamoDBInstance } from '@/dynamodb' -import { encryptedDynamoDB } from '@/dynamodb' -import type { CallableEncryptionClient } from '@/dynamodb/types' +import { type EncryptedDynamoDBInstance, encryptedDynamoDB } from '@/dynamodb' import type { EncryptionClient } from '@/encryption' -import type { EncryptionClientFor } from '@/encryption/v3' -import { encryptedTable as encryptedTableV3, types } from '@/eql/v3' -import { encryptedColumn, encryptedTable as encryptedTableV2 } from '@/schema' +import { encryptedTable, types } from '@/eql/v3' -const usersV3 = encryptedTableV3('users_v3', { - email: types.TextEq('email'), // equality → __source + __hmac - name: types.Text('name'), // storage only → __source - age: types.IntegerOrd('age'), // ordering, no HMAC → __source - meta: types.Json('meta'), // ste_vec array → __source +const users = encryptedTable('users', { + email: types.TextEq('email'), + age: types.IntegerOrd('age'), }) -const usersV2 = encryptedTableV2('users_v2', { - email: encryptedColumn('email').equality(), -}) - -// Exercises the INNERMOST `true` arm of `HasSearchTerm` (types.ts): a text -// domain that is BOTH equality- and order/range-capable — text equality is -// always HMAC-based, so it mints `__hmac`. `usersV3.age` (IntegerOrd) reaches -// the same conditional but over `number`, taking the `false` arm; without a -// text-ordering column here that `true` arm had no type-level witness. -const searchV3 = encryptedTableV3('search_v3', { - title: types.TextOrd('title'), // equality + orderAndRange over string → __source + __hmac - bio: types.TextMatch('bio'), // free-text only, no equality → __source, NO __hmac -}) - -type V3Model = { pk: string; email?: string; age?: number; role?: string } - -// The two client shapes. A v3 schema tuple yields a `TypedEncryptionClient` -// parameterised by it; a v2 set yields the nominal `EncryptionClient`. Both must -// be accepted by the factory WITHOUT a cast. -// -// Named with `EncryptionClientFor`, not `Awaited>` -// — `ReturnType` reads the LAST overload, so that idiom resolves to the nominal -// client whatever schemas you pass. Supplying an explicit type argument happens -// to dodge it today, but only because it filters the non-generic nominal -// overload out of the instantiation; give that overload a type parameter and -// this assertion would silently start checking the wrong client. -declare const typedClient: EncryptionClientFor -declare const nominalClient: EncryptionClient +declare const client: EncryptionClient +const dynamo = encryptedDynamoDB({ encryptionClient: client }) -describe('encryptedDynamoDB accepts both client shapes without a cast', () => { - it('accepts the typed client from EncryptionV3', () => { - expectTypeOf(encryptedDynamoDB).toBeCallableWith({ - encryptionClient: typedClient, - }) - expectTypeOf( - encryptedDynamoDB({ encryptionClient: typedClient }), - ).toEqualTypeOf() +describe('v3-only DynamoDB types', () => { + it('accepts the generic Encryption client', () => { + expectTypeOf(dynamo).toEqualTypeOf() }) - it('accepts the nominal EncryptionClient', () => { - expectTypeOf(encryptedDynamoDB).toBeCallableWith({ - encryptionClient: nominalClient, + it('allows storedEqlVersion only on reads', () => { + dynamo.decryptModel({ email__source: 'ciphertext' }, users, { + storedEqlVersion: 2, }) - expectTypeOf( - encryptedDynamoDB({ encryptionClient: nominalClient }), - ).toEqualTypeOf() - }) - - it('rejects a bare object that is not a client', () => { - encryptedDynamoDB({ - // @ts-expect-error - not an encryption client - encryptionClient: { nope: true }, + dynamo.bulkDecryptModels([{ email__source: 'ciphertext' }], users, { + storedEqlVersion: 2, }) - }) -}) - -const dynamo = encryptedDynamoDB({ encryptionClient: typedClient }) - -describe('write is EQL v3 only; read accepts both a v3 and a v2 table', () => { - it('encryptModel accepts a v3 table and rejects a v2 table', () => { - expectTypeOf(dynamo.encryptModel).toBeCallableWith( - { pk: 'a', email: 'a@b.com' }, - usersV3, - ) - // Write is EQL v3 only — the v2 write overload was removed, so a v2 table is - // rejected on encrypt (decrypt below still accepts it). - // @ts-expect-error - encryptModel no longer accepts an EQL v2 table - dynamo.encryptModel({ pk: 'a', email: 'a@b.com' }, usersV2) - }) - - it('bulkEncryptModels accepts a v3 table and rejects a v2 table', () => { - expectTypeOf(dynamo.bulkEncryptModels).toBeCallableWith( - [{ pk: 'a', email: 'a@b.com' }], - usersV3, - ) - // @ts-expect-error - bulkEncryptModels no longer accepts an EQL v2 table - dynamo.bulkEncryptModels([{ pk: 'a', email: 'a@b.com' }], usersV2) - }) - - it('decryptModel', () => { - expectTypeOf(dynamo.decryptModel).toBeCallableWith( - { pk: 'a', email__source: 'ct' }, - usersV3, - ) - expectTypeOf(dynamo.decryptModel).toBeCallableWith( - { pk: 'a', email__source: 'ct' }, - usersV2, - ) - }) - - it('bulkDecryptModels', () => { - expectTypeOf(dynamo.bulkDecryptModels).toBeCallableWith( - [{ pk: 'a', email__source: 'ct' }], - usersV3, - ) - expectTypeOf(dynamo.bulkDecryptModels).toBeCallableWith( - [{ pk: 'a', email__source: 'ct' }], - usersV2, - ) - }) - - it('rejects a bare object as a table', () => { - dynamo.encryptModel( - { pk: 'a' }, - // @ts-expect-error - a plain object is not an encrypted table - { email: 'not-a-table' }, - ) - }) -}) - -describe('the v3 overload types the DynamoDB storage split', () => { - it('replaces a declared column with __source, and mints __hmac only for equality', async () => { - const result = await dynamo.encryptModel( - { pk: 'a', email: 'a@b.com', age: 3, role: 'admin' }, - usersV3, - ) - if (result.failure) throw new Error(result.failure.message) - - // Equality domain: ciphertext + the queryable HMAC term. - expectTypeOf(result.data.email__source).toEqualTypeOf() - expectTypeOf(result.data.email__hmac).toEqualTypeOf() - // Ordering domain: ciphertext only — `op` has no DynamoDB query surface. - expectTypeOf(result.data.age__source).toEqualTypeOf() - expectTypeOf(result.data).not.toHaveProperty('age__hmac') - // The plaintext key is GONE — typing it as present is the defect this fixes. - expectTypeOf(result.data).not.toHaveProperty('email') - // Non-schema keys pass through untouched (pk / sk / GSI attributes). - expectTypeOf(result.data.pk).toEqualTypeOf() - expectTypeOf(result.data.role).toEqualTypeOf() - }) - - it('types a JSON document as its stored ste_vec entries plus KeyHeader', async () => { - const result = await dynamo.encryptModel( - { pk: 'a', meta: { a: 1 } }, - usersV3, - ) - if (result.failure) throw new Error(result.failure.message) - - // A SteVec document is stored as `{ h, sv }` — the entries plus the - // per-document KeyHeader `h` that protect-ffi 0.30 decrypt requires. - expectTypeOf(result.data.meta__source).toEqualTypeOf<{ - h: unknown - sv: unknown[] - }>() - expectTypeOf(result.data).not.toHaveProperty('meta__hmac') - }) - - it('applies the same storage split to the bulk return type', async () => { - const result = await dynamo.bulkEncryptModels( - [{ pk: 'a', email: 'a@b.com', age: 3 }], - usersV3, - ) - if (result.failure) throw new Error(result.failure.message) - - expectTypeOf(result.data[0].email__source).toEqualTypeOf() - expectTypeOf(result.data[0].email__hmac).toEqualTypeOf() - expectTypeOf(result.data[0].age__source).toEqualTypeOf() - expectTypeOf(result.data[0]).not.toHaveProperty('email') - }) - - it('folds bulk stored attributes back to plaintext on decrypt', async () => { - const result = await dynamo.bulkDecryptModels( - [{ pk: 'a', email__source: 'ct', email__hmac: 'hm' }], - usersV3, - ) - if (result.failure) throw new Error(result.failure.message) - - expectTypeOf(result.data[0].email).toEqualTypeOf() - expectTypeOf(result.data[0]).not.toHaveProperty('email__source') - }) - - it('rejects a field whose type does not match its column domain', () => { - // @ts-expect-error - `email` is a text domain, not a number - dynamo.encryptModel({ pk: 'a', email: 42 }, usersV3) - // @ts-expect-error - `age` is an integer domain, not a string - dynamo.encryptModel({ pk: 'a', age: 'not-a-number' }, usersV3) - }) - - it('folds the stored attributes back to plaintext on decrypt', async () => { - const result = await dynamo.decryptModel( - { pk: 'a', email__source: 'ct', email__hmac: 'hm', role: 'admin' }, - usersV3, - ) - if (result.failure) throw new Error(result.failure.message) - expectTypeOf(result.data.email).toEqualTypeOf() - // The query term is not data — it does not survive the read. - expectTypeOf(result.data).not.toHaveProperty('email__hmac') - expectTypeOf(result.data).not.toHaveProperty('email__source') - expectTypeOf(result.data.pk).toEqualTypeOf() - expectTypeOf(result.data.role).toEqualTypeOf() - }) - - it('round-trips a declared model shape', async () => { - const model: V3Model = { pk: 'a', email: 'a@b.com', role: 'admin' } - const encrypted = await dynamo.encryptModel(model, usersV3) - if (encrypted.failure) throw new Error(encrypted.failure.message) - - expectTypeOf(dynamo.decryptModel).toBeCallableWith(encrypted.data, usersV3) - }) -}) - -describe('a text-ordering domain reaches the HasSearchTerm `true` arm', () => { - it('mints __hmac for a text equality+ordering column, but not for free-text-only', async () => { - const result = await dynamo.encryptModel( - { pk: 'a', title: 'Hello', bio: 'about me' }, - searchV3, - ) - if (result.failure) throw new Error(result.failure.message) - - // TextOrd is equality- AND order/range-capable over `string`: the innermost - // `[PlaintextForColumn] extends [string] ? true : false` resolves `true`, - // so the queryable HMAC term is present. This is the arm no prior column - // instantiated — `usersV3.age` (IntegerOrd) takes the `false` (number) arm. - expectTypeOf(result.data.title__source).toEqualTypeOf() - expectTypeOf(result.data.title__hmac).toEqualTypeOf() - - // A free-text-only domain (`TextMatch`) is NOT equality-capable, so it never - // reaches the ordering/text arms and writes no HMAC term — the distinct, - // adjacent behaviour that makes the `true` arm meaningful. - expectTypeOf(result.data.bio__source).toEqualTypeOf() - expectTypeOf(result.data).not.toHaveProperty('bio__hmac') - }) -}) - -describe('decrypt passes through suffixed keys whose base names no column', () => { - it('folds a declared column but leaves unrelated __hmac / __source keys intact', async () => { - // `email` IS a declared column; `legit` and `img` are NOT. A stored item can - // legitimately carry a customer attribute that merely ends in `__hmac` - // (an app-level signature) or `__source` (a renamed/foreign column) — the - // runtime read path preserves both, and these two `DecryptedAttributes` arms - // type that: a suffixed key whose base names no column is returned untouched. - const result = await dynamo.decryptModel( - { - pk: 'a', - email__source: 'ct', - email__hmac: 'hm', - legit__hmac: 'sig', - img__source: 'raw', - }, - usersV3, - ) - if (result.failure) throw new Error(result.failure.message) - - // Declared column: `email__source` folds to `email`, `email__hmac` (its base - // IS a column) is dropped as a query term. - expectTypeOf(result.data.email).toEqualTypeOf() - expectTypeOf(result.data).not.toHaveProperty('email__source') - expectTypeOf(result.data).not.toHaveProperty('email__hmac') - - // Non-column suffixed keys: neither folded nor dropped — the two "names no - // column" arms. `legit__hmac`'s base is not a column, so unlike `email__hmac` - // it survives; `img__source`'s base is not a column, so unlike `email__source` - // it is NOT folded to `img`. - expectTypeOf(result.data.legit__hmac).toEqualTypeOf() - expectTypeOf(result.data.img__source).toEqualTypeOf() - expectTypeOf(result.data).not.toHaveProperty('img') - - // Ordinary passthrough attribute, for contrast. - expectTypeOf(result.data.pk).toEqualTypeOf() - }) -}) - -describe('a dotted-path v3 column is not modelled in EncryptedAttributes', () => { - it('passes the parent key through untyped and mints no split attribute', () => { - const dotted = encryptedTableV3('dotted_v3', { - 'profile.ssn': types.TextEq('profile.ssn'), + // @ts-expect-error - the legacy wire hint is read-only + dynamo.encryptModel({ email: 'a@b.com' }, users, { + storedEqlVersion: 2, }) - type Model = { pk: string; profile: { ssn: string; name: string } } - type Mapped = EncryptedAttributes - - // LIMITATION pinned (see the `EncryptedAttributes` docblock in types.ts): a - // column declared under a dotted path is split INSIDE the nested `profile` - // map at runtime, but the model key is `profile`, not `profile.ssn`. The - // mapped type does not recognise `profile` as a column, so it passes through - // with its input type unchanged and no `__source`/`__hmac` term is - // minted at the type level. If a future change starts (or stops) modelling - // the nested split, this assertion breaks on purpose. - expectTypeOf().toEqualTypeOf<{ - ssn: string - name: string - }>() - expectTypeOf().not.toHaveProperty('profile.ssn__source') - expectTypeOf().not.toHaveProperty('profile__source') - expectTypeOf().toEqualTypeOf() }) -}) -describe('a required-nullable v3 column keeps __source required', () => { - it('preserves the optional modifier only for optional columns', () => { - // `email` is required + nullable; `name` is optional. The `__source` half of - // `EncryptedAttributes` is a homomorphic key-remapped mapped type (types.ts), - // so it preserves the `?` modifier from the model key: a required-nullable - // column stays required, an optional column stays optional. The `| null` of - // the plaintext is dropped — `SourceAttribute` returns a flat `string` — so a - // null value writing no attribute at runtime is a deliberate, pinned mismatch - // with the optimistic type. `__hmac` is always optional regardless. - type Model = { pk: string; email: string | null; name?: string } - type Mapped = EncryptedAttributes - - expectTypeOf().toEqualTypeOf<{ - pk: string - email__source: string - email__hmac?: string - name__source?: string - }>() - }) -}) - -describe('operations chain and resolve', () => { - it('.audit() returns the operation so it stays chainable', () => { - const op = dynamo.encryptModel({ pk: 'a', email: 'a@b.com' }, usersV3) - expectTypeOf(op.audit({ metadata: { sub: 'u1' } })).toEqualTypeOf< - typeof op - >() - }) - - it('.audit() is chainable on decryptModel and returns the operation', () => { - // The DynamoDB decrypt op is chainable; audit metadata now forwards to the - // underlying client decrypt regardless of client shape (see - // resolve-decrypt.test.ts / decrypt-audit-forwarding.test.ts for the runtime - // proof). Here we lock the type-level surface. - const op = dynamo.decryptModel({ pk: 'a', email__source: 'ct' }, usersV3) - expectTypeOf(op).toHaveProperty('audit') - expectTypeOf(op.audit({ metadata: { sub: 'u1' } })).toEqualTypeOf< - typeof op - >() - }) - - it('awaiting decryptModel yields a discriminated Result', async () => { - const result = await dynamo.decryptModel( - { pk: 'a', email__source: 'ct' }, - usersV3, - ) - - if (result.failure) { - expectTypeOf(result.failure.message).toEqualTypeOf() - return - } - - expectTypeOf(result.data.email).toEqualTypeOf() - }) - - it('awaiting yields a discriminated Result', async () => { - const result = await dynamo.encryptModel( - { pk: 'a', email: 'a@b.com' }, - usersV3, - ) - - if (result.failure) { - expectTypeOf(result.failure.message).toEqualTypeOf() - // The success arm is not reachable in this branch — `data` is not even a - // property of the failure member, which is the discrimination working. - expectTypeOf(result).not.toHaveProperty('data') - return - } - - // ...and the failure arm is unreachable in this one. - expectTypeOf(result.failure).toEqualTypeOf() - expectTypeOf(result.data.email__source).toEqualTypeOf() - }) -}) - -/** - * The INTERNAL client view, `CallableEncryptionClient` — the shape the operation - * classes cast to. Distinct from everything above, which is the PUBLIC instance. - * - * All four members must return `unknown`. The shipped clients disagree about - * what an operation is: the nominal client returns a chainable - * `EncryptModelOperation`, the typed client a `MappedDecryptOperation`, and the - * wasm-inline client a bare `Promise` carrying no `.audit()` at all. - * Declaring a chainable shape here asserts an `.audit()` that entry does not - * have — and that assertion is not academic: it is what let the write path chain - * `.audit()` unconditionally and fail EVERY EQL v3 write on the wasm entry - * (#788 review follow-up). - * - * `unknown` forces every call site through `resolveEncryptResult` / - * `resolveDecryptResult`, which normalise all three shapes and fail closed. The - * encrypt members were the last two still declaring the lie. - */ -describe('the internal callable client view is shape-agnostic', () => { - it('returns unknown from all four members, so no shape is presumed', () => { - expectTypeOf< - ReturnType - >().toBeUnknown() - expectTypeOf< - ReturnType - >().toBeUnknown() - expectTypeOf< - ReturnType - >().toBeUnknown() - expectTypeOf< - ReturnType - >().toBeUnknown() + it('rejects invalid stored versions', () => { + dynamo.decryptModel({ email__source: 'ciphertext' }, users, { + // @ts-expect-error - only EQL v2 and v3 exist + storedEqlVersion: 4, + }) }) }) diff --git a/packages/stack/__tests__/dynamodb/construct-guard.test.ts b/packages/stack/__tests__/dynamodb/construct-guard.test.ts index 9ab09ea72..391db7397 100644 --- a/packages/stack/__tests__/dynamodb/construct-guard.test.ts +++ b/packages/stack/__tests__/dynamodb/construct-guard.test.ts @@ -16,7 +16,10 @@ import { describe, expect, it } from 'vitest' import { encryptedDynamoDB } from '@/dynamodb' import { encryptedTable as encryptedTableV3, types } from '@/eql/v3' -import { encryptedColumn, encryptedTable as encryptedTableV2 } from '@/schema' +import { + encryptedColumn, + encryptedTable as encryptedTableV2, +} from '@/schema/internal' const usersV3 = encryptedTableV3('users_v3', { email: types.TextEq('email'), diff --git a/packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts b/packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts index 1e2b5fd94..ecb21b973 100644 --- a/packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts +++ b/packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts @@ -4,7 +4,7 @@ * Mirrors the v2 suite in `encrypted-dynamodb.test.ts` and adds the two things * only v3 raises: * - * - both client shapes must work. `EncryptionV3` returns a `TypedEncryptionClient` + * - both client shapes must work. `Encryption` returns a `EncryptionClient` * whose `decryptModel` is a plain `Promise>` taking the table as a * second argument; `Encryption({ config: { eqlVersion: 3 } })` returns the * nominal chainable client. Audit metadata on decrypt only has somewhere to @@ -21,7 +21,7 @@ import { beforeAll, describe, expect, it } from 'vitest' import { encryptedDynamoDB } from '@/dynamodb' import { toItemWithEqlPayloads } from '@/dynamodb/helpers' import type { EncryptedDynamoDBInstance } from '@/dynamodb/types' -import { type EncryptionClientFor, EncryptionV3 } from '@/encryption/v3' +import type { EncryptionClient } from '@/encryption/v3' import type { AnyV3Table, JsonValue } from '@/eql/v3' import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' @@ -62,10 +62,10 @@ type User = { role?: string } -/** The typed client from `EncryptionV3` — the documented v3 entry point. */ +/** The typed client from `Encryption` — the documented v3 entry point. */ let typedDynamo: EncryptedDynamoDBInstance /** The nominal chainable client, forced into v3 mode. */ -let nominalClient: EncryptionClientFor +let nominalClient: EncryptionClient let nominalDynamo: EncryptedDynamoDBInstance beforeAll(async () => { @@ -75,12 +75,11 @@ beforeAll(async () => { // with every other live suite in this package. requireIntegrationEnv(['cipherstash']) - const typedClient = await EncryptionV3({ schemas: [users] }) + const typedClient = await Encryption({ schemas: [users] }) typedDynamo = encryptedDynamoDB({ encryptionClient: typedClient }) nominalClient = await Encryption({ schemas: [users] as never, - config: { eqlVersion: 3 }, }) nominalDynamo = encryptedDynamoDB({ encryptionClient: nominalClient }) }) @@ -361,12 +360,11 @@ describe('nested attributes with a v3 table', liveSuiteOptions, () => { } let nestedDynamo: EncryptedDynamoDBInstance - let nestedClient: EncryptionClientFor + let nestedClient: EncryptionClient beforeAll(async () => { nestedClient = await Encryption({ schemas: [nested] as never, - config: { eqlVersion: 3 }, }) nestedDynamo = encryptedDynamoDB({ encryptionClient: nestedClient }) }) @@ -476,12 +474,11 @@ describe( }) let renamedDynamo: EncryptedDynamoDBInstance - let renamedClient: EncryptionClientFor + let renamedClient: EncryptionClient beforeAll(async () => { renamedClient = await Encryption({ schemas: [renamed] as never, - config: { eqlVersion: 3 }, }) renamedDynamo = encryptedDynamoDB({ encryptionClient: renamedClient }) }) diff --git a/packages/stack/__tests__/dynamodb/encrypted-dynamodb.test.ts b/packages/stack/__tests__/dynamodb/encrypted-dynamodb.test.ts deleted file mode 100644 index cecad04bb..000000000 --- a/packages/stack/__tests__/dynamodb/encrypted-dynamodb.test.ts +++ /dev/null @@ -1,420 +0,0 @@ -/** - * Characterisation tests for `encryptedDynamoDB` against a live client. - * - * These pin the CURRENT (EQL v2) end-to-end behaviour of the shipping DynamoDB - * adapter before the EQL v3 port (#657): what `encryptModel` puts on the wire, - * that `decryptModel` reverses it exactly, that the `__hmac` attribute an item - * is stored under is the same value `encryptQuery` mints for a key condition, - * and how failures surface. - * - * There is no DynamoDB in the loop — the adapter never touches the AWS SDK. It - * maps between EQL payloads and DynamoDB attribute names, so these tests assert - * on the attribute map that a caller would hand to `PutCommand`. - * - * Requires CipherStash credentials (`CS_*`), like the rest of this suite. - */ -import 'dotenv/config' -import { beforeAll, describe, expect, it } from 'vitest' -import { encryptedDynamoDB } from '@/dynamodb' -import type { EncryptedDynamoDBInstance } from '@/dynamodb/types' -import type { EncryptionClient } from '@/encryption' -import { Encryption } from '@/index' -import { encryptedColumn, encryptedField, encryptedTable } from '@/schema' - -const users = encryptedTable('users', { - email: encryptedColumn('email').equality(), - name: encryptedColumn('name'), - example: { - protected: encryptedField('example.protected'), - }, -}) - -type User = { - pk: string - email?: string | null - name?: string | null - role?: string - example?: { protected?: string | null; notProtected?: string } -} - -/** - * The attribute map `encryptModel` actually writes for the v2 `users` table. - * - * The v2 overload still returns the INPUT model type. Unlike v3, a v2 column - * does not carry its index configuration in the type, so the `__source` / - * `__hmac` split cannot be derived the way `EncryptedAttributes` derives it for - * a v3 table — see the note on `EncryptedDynamoDBInstance.encryptModel`. - * Naming the wire shape here, rather than widening `User` with an index - * signature, keeps these assertions honest about what is actually stored. - */ -type StoredUser = { - pk: string - role?: string - email__source?: string - email__hmac?: string - name__source?: string - example?: { protected__source?: string; notProtected?: string } -} - -/** Read a v2 encrypt result as the attribute map it really is. */ -const storedAttrs = (item: User): StoredUser => item as StoredUser - -let client: EncryptionClient -let dynamo: EncryptedDynamoDBInstance - -beforeAll(async () => { - client = await Encryption({ schemas: [users] }) - dynamo = encryptedDynamoDB({ encryptionClient: client }) -}) - -describe('encryptModel', () => { - it('splits equality columns into __source and __hmac, and leaves plaintext alone', async () => { - const result = await dynamo.encryptModel( - { - pk: 'user#1', - email: 'alice@example.com', - name: 'Alice', - role: 'admin', - }, - users, - ) - - if (result.failure) throw new Error(result.failure.message) - - expect(Object.keys(result.data).sort()).toEqual([ - 'email__hmac', - 'email__source', - 'name__source', - 'pk', - 'role', - ]) - expect(result.data.pk).toBe('user#1') - expect(result.data.role).toBe('admin') - expect(typeof storedAttrs(result.data).email__source).toBe('string') - expect(typeof storedAttrs(result.data).email__hmac).toBe('string') - // Neither stored attribute leaks the plaintext. - expect(storedAttrs(result.data).email__source).not.toContain( - 'alice@example.com', - ) - expect(storedAttrs(result.data).email__hmac).not.toContain( - 'alice@example.com', - ) - }) - - it('gives a non-equality column a __source but no __hmac', async () => { - const result = await dynamo.encryptModel( - { pk: 'user#2', name: 'Bob' }, - users, - ) - - if (result.failure) throw new Error(result.failure.message) - - expect(result.data).toHaveProperty('name__source') - expect(result.data).not.toHaveProperty('name__hmac') - }) - - it('encrypts a nested field in place, keeping siblings plaintext', async () => { - const result = await dynamo.encryptModel( - { - pk: 'user#4', - example: { protected: 'secret', notProtected: 'public' }, - }, - users, - ) - - if (result.failure) throw new Error(result.failure.message) - - expect(result.data.example).toEqual({ - protected__source: expect.any(String), - notProtected: 'public', - }) - }) - - it('passes null through without encrypting it', async () => { - const result = await dynamo.encryptModel( - { pk: 'user#5', email: null, name: 'Carol' }, - users, - ) - - if (result.failure) throw new Error(result.failure.message) - - expect(result.data.email).toBeNull() - expect(result.data).not.toHaveProperty('email__source') - }) - - it('does not mutate the caller’s input object', async () => { - const input: User = { pk: 'user#6', email: 'dave@example.com' } - const snapshot = structuredClone(input) - - await dynamo.encryptModel(input, users) - - expect(input).toEqual(snapshot) - }) -}) - -describe('decryptModel', () => { - it('round-trips every column shape back to the original item', async () => { - const original: User = { - pk: 'user#7', - email: 'erin@example.com', - name: 'Erin', - role: 'admin', - example: { protected: 'secret', notProtected: 'public' }, - } - - const encrypted = await dynamo.encryptModel(original, users) - if (encrypted.failure) throw new Error(encrypted.failure.message) - - const decrypted = await dynamo.decryptModel(encrypted.data, users) - if (decrypted.failure) throw new Error(decrypted.failure.message) - - expect(decrypted.data).toEqual(original) - }) - - it('tolerates the __hmac attribute being present on the stored item', async () => { - const encrypted = await dynamo.encryptModel( - { pk: 'user#9', email: 'frank@example.com' }, - users, - ) - if (encrypted.failure) throw new Error(encrypted.failure.message) - - expect(encrypted.data).toHaveProperty('email__hmac') - - const decrypted = await dynamo.decryptModel(encrypted.data, users) - if (decrypted.failure) throw new Error(decrypted.failure.message) - - expect(decrypted.data.email).toBe('frank@example.com') - }) -}) - -describe('bulk operations', () => { - it('encrypts and decrypts a batch, preserving order and per-item shape', async () => { - const items: User[] = [ - { pk: 'user#10', email: 'a@example.com', name: 'A' }, - { pk: 'user#11', email: 'b@example.com', name: 'B', role: 'admin' }, - ] - - const encrypted = await dynamo.bulkEncryptModels(items, users) - if (encrypted.failure) throw new Error(encrypted.failure.message) - - expect(encrypted.data).toHaveLength(2) - for (const item of encrypted.data) { - expect(item).toHaveProperty('email__source') - expect(item).toHaveProperty('email__hmac') - } - - const decrypted = await dynamo.bulkDecryptModels( - encrypted.data, - users, - ) - if (decrypted.failure) throw new Error(decrypted.failure.message) - - expect(decrypted.data).toEqual(items) - }) - - it('handles heterogeneous items in one batch', async () => { - const items: User[] = [ - { pk: 'user#12', email: 'c@example.com' }, - { pk: 'user#13', name: 'D', example: { protected: 'secret' } }, - ] - - const encrypted = await dynamo.bulkEncryptModels(items, users) - if (encrypted.failure) throw new Error(encrypted.failure.message) - - const decrypted = await dynamo.bulkDecryptModels( - encrypted.data, - users, - ) - if (decrypted.failure) throw new Error(decrypted.failure.message) - - expect(decrypted.data).toEqual(items) - }) - - it('accepts an empty batch', async () => { - const encrypted = await dynamo.bulkEncryptModels([], users) - if (encrypted.failure) throw new Error(encrypted.failure.message) - - expect(encrypted.data).toEqual([]) - }) -}) - -describe('the __hmac key-condition path', () => { - it('mints, via encryptQuery, the same HMAC the item is stored under', async () => { - const email = 'grace@example.com' - - const stored = await dynamo.encryptModel( - { pk: 'user#14', email }, - users, - ) - if (stored.failure) throw new Error(stored.failure.message) - - const term = await client.encryptQuery(email, { - table: users, - column: users.email, - queryType: 'equality', - }) - if (term.failure) throw new Error(term.failure.message) - - // This equality is the whole DynamoDB query story: a caller puts - // `term.data.hm` into `KeyConditionExpression: "email__hmac = :e"` and it - // matches the attribute written at encrypt time. - expect((term.data as { hm: string }).hm).toBe( - storedAttrs(stored.data).email__hmac, - ) - }) - - it('mints a different HMAC for a different plaintext', async () => { - const term = await client.encryptQuery('someone-else@example.com', { - table: users, - column: users.email, - queryType: 'equality', - }) - if (term.failure) throw new Error(term.failure.message) - - const stored = await dynamo.encryptModel( - { pk: 'user#15', email: 'grace@example.com' }, - users, - ) - if (stored.failure) throw new Error(stored.failure.message) - - expect((term.data as { hm: string }).hm).not.toBe( - storedAttrs(stored.data).email__hmac, - ) - }) - - it('is deterministic across separate encryptions of the same value', async () => { - const email = 'heidi@example.com' - - const first = await dynamo.encryptModel({ pk: 'a', email }, users) - const second = await dynamo.encryptModel({ pk: 'b', email }, users) - if (first.failure) throw new Error(first.failure.message) - if (second.failure) throw new Error(second.failure.message) - - expect(storedAttrs(first.data).email__hmac).toBe( - storedAttrs(second.data).email__hmac, - ) - // ...while the ciphertext itself is not deterministic. - expect(storedAttrs(first.data).email__source).not.toBe( - storedAttrs(second.data).email__source, - ) - }) -}) - -describe('audit metadata', () => { - it('is accepted on every operation without changing the result', async () => { - const metadata = { sub: 'user-id-123', action: 'characterisation' } - const item: User = { - pk: 'user#16', - email: 'ivan@example.com', - name: 'Ivan', - } - - const encrypted = await dynamo - .encryptModel(item, users) - .audit({ metadata }) - if (encrypted.failure) throw new Error(encrypted.failure.message) - expect(encrypted.data).toHaveProperty('email__hmac') - - const decrypted = await dynamo - .decryptModel(encrypted.data, users) - .audit({ metadata }) - if (decrypted.failure) throw new Error(decrypted.failure.message) - expect(decrypted.data).toEqual(item) - - const bulkEncrypted = await dynamo - .bulkEncryptModels([item], users) - .audit({ metadata }) - if (bulkEncrypted.failure) throw new Error(bulkEncrypted.failure.message) - - const bulkDecrypted = await dynamo - .bulkDecryptModels(bulkEncrypted.data, users) - .audit({ metadata }) - if (bulkDecrypted.failure) throw new Error(bulkDecrypted.failure.message) - expect(bulkDecrypted.data).toEqual([item]) - }) - - it('returns the operation itself so .audit() can be chained before awaiting', () => { - const operation = dynamo.encryptModel({ pk: 'x' }, users) - - expect(operation.audit({ metadata: {} })).toBe(operation) - }) -}) - -describe('error handling', () => { - const unknownColumn = encryptedTable('users', { - nope: encryptedColumn('nonexistent_column'), - }) - - it('surfaces the FFI error code when encrypting an unregistered column', async () => { - const result = await dynamo.encryptModel( - { nope: 'value' }, - // Not among the client's schemas — the FFI rejects the column. - unknownColumn, - ) - - expect(result.failure).toBeDefined() - expect(result.failure?.code).toBe('UNKNOWN_COLUMN') - }) - - it('surfaces the FFI error code on the bulk encrypt path', async () => { - const result = await dynamo.bulkEncryptModels( - [{ nope: 'value' }], - unknownColumn, - ) - - expect(result.failure).toBeDefined() - expect(result.failure?.code).toBe('UNKNOWN_COLUMN') - }) - - it('fails with a DynamoDB error code when __source is not a ciphertext', async () => { - const result = await dynamo.decryptModel( - { email__source: 'not-a-ciphertext' }, - users, - ) - - expect(result.failure).toBeDefined() - expect(result.failure?.name).toBe('EncryptedDynamoDBError') - // No FFI code on this path, so the adapter's own fallback code is used. - expect(result.failure?.code).toBe('DYNAMODB_ENCRYPTION_ERROR') - expect(result.failure?.details).toEqual({ context: 'decryptModel' }) - }) - - it('fails on the bulk decrypt path for malformed ciphertext', async () => { - const result = await dynamo.bulkDecryptModels( - [{ email__source: 'not-a-ciphertext' }], - users, - ) - - expect(result.failure).toBeDefined() - expect(result.failure?.code).toBe('DYNAMODB_ENCRYPTION_ERROR') - expect(result.failure?.details).toEqual({ context: 'bulkDecryptModels' }) - }) - - it('fails the whole batch when one item is undecryptable (all-or-nothing)', async () => { - const result = await dynamo.bulkDecryptModels( - // One malformed item alongside a well-formed plaintext-only one. - [{ email__source: 'not-a-ciphertext' }, { pk: 'ok' }], - users, - ) - - // The batch is all-or-nothing: a single bad item fails the whole call, and - // no partial data is returned. - expect(result.failure).toBeDefined() - expect((result as { data?: unknown }).data).toBeUndefined() - }) - - it('routes failures to the configured errorHandler', async () => { - const seen: { code: string; message: string }[] = [] - const instrumented = encryptedDynamoDB({ - encryptionClient: client, - options: { - errorHandler: (e) => seen.push({ code: e.code, message: e.message }), - }, - }) - - await instrumented.encryptModel({ nope: 'value' }, unknownColumn) - - expect(seen).toHaveLength(1) - expect(seen[0]?.code).toBe('UNKNOWN_COLUMN') - }) -}) diff --git a/packages/stack/__tests__/dynamodb/helpers-v3.test.ts b/packages/stack/__tests__/dynamodb/helpers-v3.test.ts index 543b2eb51..edc0f6b00 100644 --- a/packages/stack/__tests__/dynamodb/helpers-v3.test.ts +++ b/packages/stack/__tests__/dynamodb/helpers-v3.test.ts @@ -17,16 +17,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { buildReadContext, deepClone, - isV3Table, toEncryptedDynamoItem, toItemWithEqlPayloads, } from '@/dynamodb/helpers' import { encryptedTable, types } from '@/eql/v3' -import { - encryptedColumn, - encryptedField, - encryptedTable as encryptedTableV2, -} from '@/schema' import { logger } from '@/utils/logger' const users = encryptedTable('users', { @@ -60,28 +54,6 @@ const scalar = ( ...terms, }) -describe('isV3Table', () => { - it('recognises a v3 table by its concrete-domain columns', () => { - expect(isV3Table(users)).toBe(true) - }) - - it('recognises a flat v2 table', () => { - const v2 = encryptedTableV2('users', { - email: encryptedColumn('email').equality(), - }) - - expect(isV3Table(v2)).toBe(false) - }) - - it('recognises a v2 table whose columns are nested under a group', () => { - const v2 = encryptedTableV2('users', { - example: { protected: encryptedField('example.protected') }, - }) - - expect(isV3Table(v2)).toBe(false) - }) -}) - describe('toEncryptedDynamoItem with v3 payloads', () => { it('splits an untagged v3 scalar into __source and __hmac', () => { const result = toEncryptedDynamoItem( @@ -190,12 +162,14 @@ describe('toItemWithEqlPayloads for a v3 table', () => { }) }) - it('still emits a v2 envelope for a v2 table', () => { - const v2 = encryptedTableV2('users', { - email: encryptedColumn('email').equality(), - }) - - expect(toItemWithEqlPayloads({ email__source: 'ct' }, v2)).toEqual({ + it('emits a v2 envelope when legacy storage is selected explicitly', () => { + expect( + toItemWithEqlPayloads( + { email__source: 'ct' }, + users, + buildReadContext(users, 2), + ), + ).toEqual({ email: { i: { c: 'email', t: 'users' }, v: 2, k: 'ct', c: 'ct' }, }) }) @@ -332,7 +306,7 @@ describe('regressions found in review', () => { // envelope in place so write and read stay symmetric and it round-trips. const item = { profile: { secret: scalar('secret', 'CT', { hm: 'H' }) } } - const stored = toEncryptedDynamoItem(item, encryptedAttrs, true) + const stored = toEncryptedDynamoItem(item, encryptedAttrs) expect(stored).toEqual(item) expect(toItemWithEqlPayloads(stored, users)).toEqual(item) @@ -400,7 +374,7 @@ describe('arrays are a deliberate carve-out', () => { const item = { tags: [scalar('tags', 'CT', { hm: 'H' })] } // Whole envelope retained (v, i, c, hm all present); no tags__source/__hmac. - expect(toEncryptedDynamoItem(item, encryptedAttrs, true)).toEqual(item) + expect(toEncryptedDynamoItem(item, encryptedAttrs)).toEqual(item) }) it('passes an envelope nested in an array straight through on read', () => { @@ -414,7 +388,7 @@ describe('arrays are a deliberate carve-out', () => { it('round-trips a payload nested in an array', () => { const item = { tags: [scalar('tags', 'CT', { hm: 'H' })] } - const stored = toEncryptedDynamoItem(item, encryptedAttrs, true) + const stored = toEncryptedDynamoItem(item, encryptedAttrs) expect(stored).toEqual(item) expect(toItemWithEqlPayloads(stored, users)).toEqual(item) }) @@ -425,7 +399,7 @@ describe('arrays are a deliberate carve-out', () => { // being false stops a split. That guard interaction is otherwise untested. const item = { email: [scalar('email', 'CT', { hm: 'H' })] } - const stored = toEncryptedDynamoItem(item, encryptedAttrs, true) + const stored = toEncryptedDynamoItem(item, encryptedAttrs) expect(stored).toEqual(item) expect(stored).not.toHaveProperty('email__source') expect(toItemWithEqlPayloads(stored, users)).toEqual(item) @@ -441,7 +415,7 @@ describe('arrays are a deliberate carve-out', () => { ], } - const stored = toEncryptedDynamoItem(item, encryptedAttrs, true) + const stored = toEncryptedDynamoItem(item, encryptedAttrs) expect(stored).toEqual(item) expect(toItemWithEqlPayloads(stored, users)).toEqual(item) }) @@ -527,3 +501,146 @@ describe('deepClone preserves structured values', () => { expect(cloned.pk).toBe('u#1') }) }) + +describe('stored EQL v2 grouped fields', () => { + /** + * A v2 grouped column registered its build key on the BARE LEAF, so a field + * inside a group was stored as `.__source` while the schema knew + * it only as ``. The v3 rewrite made matching exact-dotted-path only + * for both generations, which silently orphaned every such attribute: it + * reads back as raw base64 inside a `{ data }` success. + * + * The fallback is gated on the stored generation. A v3 table registers full + * dotted paths precisely so a nested leaf cannot collide with a top-level + * column ("does not rebuild a nested __source whose leaf collides" + * above), and that guard must keep holding for v3. + */ + const orders = encryptedTable('orders', { + amount: types.TextEq('amount'), + }) + + it('rebuilds a nested v2 attribute registered under its bare leaf', () => { + const item = { + pk: 'order#1', + details: { amount__source: 'BASE64CT', amount__hmac: 'HMAC' }, + } + + expect( + toItemWithEqlPayloads(item, orders, buildReadContext(orders, 2)), + ).toEqual({ + pk: 'order#1', + details: { + amount: { + i: { c: 'amount', t: 'orders' }, + v: 2, + k: 'ct', + c: 'BASE64CT', + }, + }, + }) + }) + + it('leaves the same item untouched when the stored generation is v3', () => { + const item = { + pk: 'order#1', + details: { amount__source: 'BASE64CT', amount__hmac: 'HMAC' }, + } + + expect( + toItemWithEqlPayloads(item, orders, buildReadContext(orders, 3)), + ).toEqual(item) + }) +}) + +/** + * The bare-leaf fallback matches `details.amount__source` against the REGISTERED + * path `amount`, but the rebuilt envelope is re-nested by the recursion, so the + * plaintext lands at `details.amount`. Both reconstructors resolve date columns + * from the registered paths alone (`client-v3.ts` `rowReconstructor`, and + * `wasm-inline.ts` `dateFields`), so neither sees `details.amount` and a legacy + * grouped date column reads back as an ISO string instead of a `Date`. + * + * The read path is the only layer that knows the alias happened, so it reports + * the ACTUAL path it wrote to and the adapter reconstructs there. + */ +describe('stored EQL v2 grouped date fields', () => { + const orders = encryptedTable('orders', { + placedAt: types.DateEq('placed_at'), + reference: types.TextEq('reference'), + }) + + const groupedItem = (leaf: string) => ({ + pk: 'order#1', + details: { [`${leaf}__source`]: 'BASE64CT', [`${leaf}__hmac`]: 'HMAC' }, + }) + + it('reports the actual nested path a bare-leaf-matched date column landed at', () => { + const aliasedDatePaths = new Set() + + toItemWithEqlPayloads( + groupedItem('placedAt'), + orders, + buildReadContext(orders, 2), + aliasedDatePaths, + ) + + expect([...aliasedDatePaths]).toEqual(['details.placedAt']) + }) + + it('reports nothing for a bare-leaf-matched column that is not date-like', () => { + const aliasedDatePaths = new Set() + + toItemWithEqlPayloads( + groupedItem('reference'), + orders, + buildReadContext(orders, 2), + aliasedDatePaths, + ) + + expect([...aliasedDatePaths]).toEqual([]) + }) + + it('reports nothing for a stored v3 read, where the fallback never fires', () => { + const aliasedDatePaths = new Set() + + toItemWithEqlPayloads( + groupedItem('placedAt'), + orders, + buildReadContext(orders, 3), + aliasedDatePaths, + ) + + expect([...aliasedDatePaths]).toEqual([]) + }) + + it('reports nothing for a top-level date column, which the client already reconstructs', () => { + const aliasedDatePaths = new Set() + + toItemWithEqlPayloads( + { pk: 'order#1', placedAt__source: 'BASE64CT' }, + orders, + buildReadContext(orders, 2), + aliasedDatePaths, + ) + + expect([...aliasedDatePaths]).toEqual([]) + }) + + it('reports nothing for a nested date column registered under its full dotted path', () => { + // Registered as `details.placedAt`, so the EXACT match fires, not the + // fallback — the client reconstructs this path itself. + const dotted = encryptedTable('orders', { + 'details.placedAt': types.DateEq('placed_at'), + }) + const aliasedDatePaths = new Set() + + toItemWithEqlPayloads( + groupedItem('placedAt'), + dotted, + buildReadContext(dotted, 2), + aliasedDatePaths, + ) + + expect([...aliasedDatePaths]).toEqual([]) + }) +}) diff --git a/packages/stack/__tests__/dynamodb/helpers.test.ts b/packages/stack/__tests__/dynamodb/helpers.test.ts deleted file mode 100644 index 37f4ef1ce..000000000 --- a/packages/stack/__tests__/dynamodb/helpers.test.ts +++ /dev/null @@ -1,349 +0,0 @@ -/** - * Characterisation tests for the pure DynamoDB attribute-mapping helpers. - * - * These pin the CURRENT (EQL v2) behaviour of `toEncryptedDynamoItem` and - * `toItemWithEqlPayloads` before the EQL v3 port (#657). They need no - * credentials and make no network calls — every assertion is about the shape - * of the attribute map on the way into and out of DynamoDB. - * - * Read them as a specification of the storage format: - * `` (an EQL payload) <-> `__source` (+ `__hmac`) - */ -import { describe, expect, it } from 'vitest' -import { - ciphertextAttrSuffix, - deepClone, - EncryptedDynamoDBErrorImpl, - handleError, - searchTermAttrSuffix, - toEncryptedDynamoItem, - toItemWithEqlPayloads, -} from '@/dynamodb/helpers' -import { encryptedColumn, encryptedField, encryptedTable } from '@/schema' - -const users = encryptedTable('users', { - email: encryptedColumn('email').equality(), - name: encryptedColumn('name'), - blob: encryptedColumn('blob').dataType('json'), - example: { - protected: encryptedField('example.protected'), - }, -}) - -const encryptedAttrs = Object.keys(users.build().columns) - -/** A minimal v2 scalar-ciphertext payload as the FFI returns it. */ -const ct = (c: string, hm?: string) => ({ - k: 'ct' as const, - v: 2, - i: { t: 'users', c: 'email' }, - c, - ...(hm ? { hm } : {}), -}) - -describe('attribute suffixes', () => { - it('are the documented DynamoDB naming convention', () => { - expect(ciphertextAttrSuffix).toBe('__source') - expect(searchTermAttrSuffix).toBe('__hmac') - }) -}) - -describe('toEncryptedDynamoItem (write path)', () => { - it('splits a ciphertext payload with an HMAC into __source and __hmac', () => { - const result = toEncryptedDynamoItem( - { pk: 'user#1', email: ct('ciphertext', 'hmac-value') }, - encryptedAttrs, - ) - - expect(result).toEqual({ - pk: 'user#1', - email__source: 'ciphertext', - email__hmac: 'hmac-value', - }) - // The original attribute name is consumed, not retained alongside. - expect(result).not.toHaveProperty('email') - }) - - it('emits only __source when the payload carries no HMAC', () => { - const result = toEncryptedDynamoItem( - { name: { ...ct('ciphertext'), i: { t: 'users', c: 'name' } } }, - encryptedAttrs, - ) - - expect(result).toEqual({ name__source: 'ciphertext' }) - expect(result).not.toHaveProperty(`name${searchTermAttrSuffix}`) - }) - - it('passes attributes absent from the schema through untouched', () => { - const item = { - pk: 'user#1', - role: 'admin', - count: 42, - flag: true, - tags: ['a', 'b'], - } - - expect(toEncryptedDynamoItem(item, encryptedAttrs)).toEqual(item) - }) - - it('preserves null and undefined without adding suffixed attributes', () => { - const result = toEncryptedDynamoItem( - { email: null, name: undefined }, - encryptedAttrs, - ) - - expect(result).toEqual({ email: null, name: undefined }) - }) - - it('recurses into nested objects and splits payloads found inside', () => { - const result = toEncryptedDynamoItem( - { - example: { - protected: { ...ct('nested-ct'), i: { t: 'users', c: 'protected' } }, - notProtected: 'plaintext', - }, - }, - encryptedAttrs, - ) - - expect(result).toEqual({ - example: { - protected__source: 'nested-ct', - notProtected: 'plaintext', - }, - }) - }) - - it('leaves arrays untouched rather than recursing into them', () => { - const result = toEncryptedDynamoItem( - { items: [{ c: 'looks-like-a-payload' }] }, - encryptedAttrs, - ) - - expect(result).toEqual({ items: [{ c: 'looks-like-a-payload' }] }) - }) - - it('leaves a real v2 payload inside an array whole and round-trips it', () => { - // The array carve-out with a genuine `v+i+c` payload (the case above uses a - // `{ c }` lookalike the detector skips anyway): stored whole, not split, and - // symmetrically passed through on read so it still decrypts. - const item = { tags: [ct('array-ct', 'array-hmac')] } - - const stored = toEncryptedDynamoItem(item, encryptedAttrs) - expect(stored).toEqual(item) - expect(stored).not.toHaveProperty('tags__source') - expect(toItemWithEqlPayloads(stored, users)).toEqual(item) - }) - - it('does not split a nested payload whose leaf names no declared v2 column', () => { - // The v2 branch carries the bare-leaf fallback; its POSITIVE case is tested - // above. The negative — a nested leaf that is NOT a declared column must be - // left whole (the read path rebuilds only declared columns, so a split here - // would be unrecoverable). Only the v3 branch (no fallback) covered this. - const item = { profile: { secret: ct('CT', 'H') } } - - const stored = toEncryptedDynamoItem(item, encryptedAttrs) - expect(stored).toEqual(item) - expect(toItemWithEqlPayloads(stored, users)).toEqual(item) - }) -}) - -describe('toItemWithEqlPayloads (read path)', () => { - it('rebuilds a v2 scalar ciphertext envelope from __source', () => { - const result = toItemWithEqlPayloads( - { pk: 'user#1', email__source: 'ciphertext' }, - users, - ) - - expect(result).toEqual({ - pk: 'user#1', - email: { - i: { c: 'email', t: 'users' }, - v: 2, - k: 'ct', - c: 'ciphertext', - }, - }) - }) - - it('drops the __hmac attribute — it is not part of the envelope', () => { - const result = toItemWithEqlPayloads( - { email__source: 'ciphertext', email__hmac: 'hmac-value' }, - users, - ) - - expect(Object.keys(result)).toEqual(['email']) - expect(result.email).not.toHaveProperty('hm') - }) - - it('rebuilds a scalar envelope for a JSON column without a ste_vec index', () => { - const result = toItemWithEqlPayloads({ blob__source: 'ciphertext' }, users) - - expect(result).toEqual({ - blob: { - i: { c: 'blob', t: 'users' }, - v: 2, - k: 'ct', - c: 'ciphertext', - }, - }) - }) - - it('rebuilds nested payloads, identifying them by their registered dotted name', () => { - const result = toItemWithEqlPayloads( - { example: { protected__source: 'nested-ct', notProtected: 'plain' } }, - users, - ) - - expect(result).toEqual({ - example: { - protected: { - // The column is registered as `example.protected`, so that is what - // the rebuilt identifier carries. This originally emitted the bare - // leaf (`protected`), which only worked because the FFI treats `i` as - // a detection key and never validates it against the ciphertext. - i: { c: 'example.protected', t: 'users' }, - v: 2, - k: 'ct', - c: 'nested-ct', - }, - notProtected: 'plain', - }, - }) - }) - - it('falls back to the leaf name when only the leaf is registered', () => { - // `encryptedField('amount')` under a `details` group — the convention the - // schema docs show — registers `amount`, not `details.amount`. - const orders = encryptedTable('orders', { - details: { amount: encryptedField('amount') }, - }) - - const result = toItemWithEqlPayloads( - { details: { amount__source: 'ct' } }, - orders, - ) - - expect(result).toEqual({ - details: { - amount: { i: { c: 'amount', t: 'orders' }, v: 2, k: 'ct', c: 'ct' }, - }, - }) - }) - - it('splits and drops a nested v2 grouped field __hmac via the bare-leaf fallback', () => { - // `encryptedField('amount')` under a `details` group registers the bare - // leaf `amount`. A nested equality payload must split to `amount__hmac` on - // write and have it dropped on read through the v2 bare-leaf fallback — the - // v2 twin of the v3 dotted-path __hmac coverage, which was otherwise only - // exercised in the live suite. - const orders = encryptedTable('orders', { - details: { amount: encryptedField('amount') }, - }) - const attrs = Object.keys(orders.build().columns) - - const stored = toEncryptedDynamoItem( - { - details: { - amount: { - k: 'ct', - v: 2, - i: { t: 'orders', c: 'amount' }, - c: 'ct', - hm: 'h', - }, - }, - }, - attrs, - ) - expect(stored).toEqual({ - details: { amount__source: 'ct', amount__hmac: 'h' }, - }) - - expect(toItemWithEqlPayloads(stored, orders)).toEqual({ - details: { - amount: { i: { c: 'amount', t: 'orders' }, v: 2, k: 'ct', c: 'ct' }, - }, - }) - }) - - it('passes plaintext attributes and null/undefined through untouched', () => { - const item = { pk: 'user#1', role: 'admin', a: null, b: undefined } - - expect(toItemWithEqlPayloads(item, users)).toEqual(item) - }) - - it('round-trips an item through both directions', () => { - const payloads = { - pk: 'user#1', - email: ct('email-ct', 'email-hmac'), - role: 'admin', - } - - const stored = toEncryptedDynamoItem(payloads, encryptedAttrs) - const rebuilt = toItemWithEqlPayloads(stored, users) - - // The HMAC is not recoverable from the rebuilt envelope: it lives only in - // the `__hmac` attribute, which the read path deliberately discards. - expect(rebuilt).toEqual({ - pk: 'user#1', - email: { i: { c: 'email', t: 'users' }, v: 2, k: 'ct', c: 'email-ct' }, - role: 'admin', - }) - }) -}) - -describe('deepClone', () => { - it('returns primitives unchanged', () => { - expect(deepClone(1)).toBe(1) - expect(deepClone('a')).toBe('a') - expect(deepClone(null)).toBe(null) - expect(deepClone(undefined)).toBe(undefined) - }) - - it('clones nested objects and arrays without sharing references', () => { - const source = { a: { b: [1, { c: 2 }] } } - const clone = deepClone(source) - - expect(clone).toEqual(source) - expect(clone.a).not.toBe(source.a) - expect(clone.a.b).not.toBe(source.a.b) - }) -}) - -describe('handleError', () => { - it('falls back to DYNAMODB_ENCRYPTION_ERROR for a plain Error', () => { - const error = handleError(new Error('boom'), 'encryptModel') - - expect(error).toBeInstanceOf(EncryptedDynamoDBErrorImpl) - expect(error.name).toBe('EncryptedDynamoDBError') - expect(error.message).toBe('boom') - expect(error.code).toBe('DYNAMODB_ENCRYPTION_ERROR') - expect(error.details).toEqual({ context: 'encryptModel' }) - }) - - it('preserves a `code` carried on the thrown object', () => { - const thrown = Object.assign(new Error('nope'), { code: 'UNKNOWN_COLUMN' }) - - expect(handleError(thrown, 'encryptModel').code).toBe('UNKNOWN_COLUMN') - }) - - it('stringifies a non-Error throw', () => { - expect(handleError('just a string', 'decryptModel').message).toBe( - 'just a string', - ) - }) - - it('invokes the errorHandler and logger callbacks when supplied', () => { - const seen: unknown[] = [] - const logged: string[] = [] - - const error = handleError(new Error('boom'), 'decryptModel', { - errorHandler: (e) => seen.push(e), - logger: { error: (message) => logged.push(message) }, - }) - - expect(seen).toEqual([error]) - expect(logged).toEqual(['Error in decryptModel']) - }) -}) diff --git a/packages/stack/__tests__/dynamodb/properties.test.ts b/packages/stack/__tests__/dynamodb/properties.test.ts index f72ca706d..b9a1b08c1 100644 --- a/packages/stack/__tests__/dynamodb/properties.test.ts +++ b/packages/stack/__tests__/dynamodb/properties.test.ts @@ -38,17 +38,11 @@ import { describe, expect, it } from 'vitest' import { ciphertextAttrSuffix, deepClone, - isV3Table, searchTermAttrSuffix, toEncryptedDynamoItem, toItemWithEqlPayloads, } from '@/dynamodb/helpers' import { encryptedTable, types } from '@/eql/v3' -import { - encryptedColumn, - encryptedField, - encryptedTable as encryptedTableV2, -} from '@/schema' // --------------------------------------------------------------------------- // Generators @@ -351,8 +345,6 @@ describe('property: the rebuilt wire version follows the table', () => { fc.assert( fc.property(safeName, ciphertext, (name, ct) => { const table = encryptedTable('t', { [name]: types.TextEq(name) }) - expect(isV3Table(table)).toBe(true) - const rebuilt = toItemWithEqlPayloads( { [`${name}${ciphertextAttrSuffix}`]: ct }, table, @@ -388,45 +380,6 @@ describe('property: the rebuilt wire version follows the table', () => { ), ) }) - - it('a v2 table always yields v: 2 with k: "ct"', () => { - fc.assert( - fc.property(safeName, ciphertext, (name, ct) => { - const table = encryptedTableV2('t', { - [name]: encryptedColumn(name).equality(), - }) - expect(isV3Table(table)).toBe(false) - - const rebuilt = toItemWithEqlPayloads( - { [`${name}${ciphertextAttrSuffix}`]: ct }, - table, - )[name] as Record - - expect(rebuilt.v).toBe(2) - expect(rebuilt.k).toBe('ct') - expect(rebuilt.c).toBe(ct) - }), - ) - }) - - it('a v2 table with a grouped field still yields v: 2 with k: "ct"', () => { - fc.assert( - fc.property(safeName, safeName, ciphertext, (group, leaf, ct) => { - const table = encryptedTableV2('t', { - [group]: { [leaf]: encryptedField(`${group}.${leaf}`) }, - }) - expect(isV3Table(table)).toBe(false) - - const rebuilt = toItemWithEqlPayloads( - { [group]: { [`${leaf}${ciphertextAttrSuffix}`]: ct } }, - table, - )[group] as Record> - - expect(rebuilt[leaf]?.v).toBe(2) - expect(rebuilt[leaf]?.k).toBe('ct') - }), - ) - }) }) // --------------------------------------------------------------------------- @@ -580,7 +533,7 @@ describe('property: a payload inside an array is stored and read whole', () => { [arrKey]: [{ v: 3, i: { t: 't', c: col }, c: ct, hm: 'H' }], } - const stored = toEncryptedDynamoItem(item, attrs, true) + const stored = toEncryptedDynamoItem(item, attrs) expect(stored).toEqual(item) expect(toItemWithEqlPayloads(stored, table)).toEqual(item) }), @@ -607,7 +560,7 @@ describe('property: the write path never splits a payload naming no declared col const payload = { v: 3, i: { t: 't', c: other }, c: ct, hm: 'H' } const item = { [other]: payload, grp: { [other]: payload } } - const stored = toEncryptedDynamoItem(item, attrs, true) + const stored = toEncryptedDynamoItem(item, attrs) expect(stored).toEqual(item) expect(toItemWithEqlPayloads(stored, table)).toEqual(item) }), diff --git a/packages/stack/__tests__/dynamodb/v2-grouped-date.test.ts b/packages/stack/__tests__/dynamodb/v2-grouped-date.test.ts new file mode 100644 index 000000000..8557d96ce --- /dev/null +++ b/packages/stack/__tests__/dynamodb/v2-grouped-date.test.ts @@ -0,0 +1,160 @@ +/** + * A legacy grouped v2 field is stored as `.__source` while the v2 + * schema knew it only as ``, so the read path's bare-leaf fallback matches + * it against the registered column `placedAt` and writes the rebuilt envelope + * back at `details.placedAt`. + * + * Both clients resolve their date columns from the REGISTERED paths — native via + * `rowReconstructor` (`encryption/client-v3.ts`), WASM via `dateFields` + * (`wasm-inline.ts`) — so neither reconstructs `details.placedAt` and the value + * came back as an ISO string. The adapter is the only layer that knows the alias + * happened, so it reconstructs there, which covers both entries at once. + * + * The stubs below return plaintext rather than recording calls: the defect is in + * the value handed back to the caller, so only asserting on the returned row can + * catch it. + */ +import { describe, expect, it } from 'vitest' +import { encryptedDynamoDB } from '@/dynamodb' +import { encryptedTable, types } from '@/eql/v3' + +const orders = encryptedTable('orders', { + placedAt: types.DateEq('placed_at'), + reference: types.TextEq('reference'), +}) + +const ISO = '2024-01-01T00:00:00.000Z' + +/** A client whose decrypt returns a fixed plaintext row, ignoring its input. */ +function returningClient(rows: Record[]) { + return { + getEncryptConfig: () => ({ tables: { orders: {} } }), + encryptModel: () => Promise.resolve({ data: {} }), + bulkEncryptModels: () => Promise.resolve({ data: [] }), + decryptModel: () => Promise.resolve({ data: rows[0] }), + bulkDecryptModels: () => Promise.resolve({ data: rows }), + } as never +} + +const groupedItem = (leaf: string) => ({ + pk: 'order#1', + details: { [`${leaf}__source`]: 'BASE64CT', [`${leaf}__hmac`]: 'HMAC' }, +}) + +describe('legacy grouped v2 date reconstruction', () => { + it('reconstructs a Date at the nested path a bare-leaf match landed at', async () => { + const dynamo = encryptedDynamoDB({ + encryptionClient: returningClient([ + { pk: 'order#1', details: { placedAt: ISO } }, + ]), + }) + + const result = await dynamo.decryptModel(groupedItem('placedAt'), orders, { + storedEqlVersion: 2, + }) + + expect(result.failure).toBeUndefined() + const details = (result.data as { details: { placedAt: unknown } }).details + expect(details.placedAt).toBeInstanceOf(Date) + expect((details.placedAt as Date).toISOString()).toBe(ISO) + }) + + it('reconstructs on the bulk path, per item', async () => { + const dynamo = encryptedDynamoDB({ + encryptionClient: returningClient([ + { pk: 'order#1', details: { placedAt: ISO } }, + { pk: 'order#2', details: { placedAt: '2025-06-15T12:30:00.000Z' } }, + ]), + }) + + const result = await dynamo.bulkDecryptModels( + [groupedItem('placedAt'), groupedItem('placedAt')], + orders, + { storedEqlVersion: 2 }, + ) + + expect(result.failure).toBeUndefined() + const rows = result.data as { details: { placedAt: unknown } }[] + expect(rows[0]?.details.placedAt).toBeInstanceOf(Date) + expect(rows[1]?.details.placedAt).toBeInstanceOf(Date) + }) + + /** + * Items in one bulk call are heterogeneous: `details.placedAt` is an encrypted + * date column in the first item and an ordinary plaintext attribute in the + * second. Collecting aliased paths into one shared set would convert the + * second item's string too, so the paths are collected per item. + */ + it('does not carry one item aliased paths onto another', async () => { + const dynamo = encryptedDynamoDB({ + encryptionClient: returningClient([ + { pk: 'order#1', details: { placedAt: ISO } }, + { pk: 'order#2', details: { placedAt: ISO } }, + ]), + }) + + const result = await dynamo.bulkDecryptModels( + [groupedItem('placedAt'), { pk: 'order#2', details: { placedAt: ISO } }], + orders, + { storedEqlVersion: 2 }, + ) + + const rows = result.data as { details: { placedAt: unknown } }[] + expect(rows[0]?.details.placedAt).toBeInstanceOf(Date) + expect(rows[1]?.details.placedAt).toBe(ISO) + }) + + it('leaves a bare-leaf-matched text column as a string', async () => { + const dynamo = encryptedDynamoDB({ + encryptionClient: returningClient([ + { pk: 'order#1', details: { reference: ISO } }, + ]), + }) + + const result = await dynamo.decryptModel(groupedItem('reference'), orders, { + storedEqlVersion: 2, + }) + + expect( + (result.data as { details: { reference: unknown } }).details.reference, + ).toBe(ISO) + }) + + it('leaves unrelated plaintext attributes untouched', async () => { + const dynamo = encryptedDynamoDB({ + encryptionClient: returningClient([ + { + pk: 'order#1', + note: ISO, + details: { placedAt: ISO, memo: ISO }, + }, + ]), + }) + + const result = await dynamo.decryptModel(groupedItem('placedAt'), orders, { + storedEqlVersion: 2, + }) + + const data = result.data as { + note: unknown + details: { placedAt: unknown; memo: unknown } + } + expect(data.note).toBe(ISO) + expect(data.details.memo).toBe(ISO) + expect(data.details.placedAt).toBeInstanceOf(Date) + }) + + it('does not reconstruct on a stored v3 read, where the fallback never fires', async () => { + const dynamo = encryptedDynamoDB({ + encryptionClient: returningClient([ + { pk: 'order#1', details: { placedAt: ISO } }, + ]), + }) + + const result = await dynamo.decryptModel(groupedItem('placedAt'), orders) + + expect( + (result.data as { details: { placedAt: unknown } }).details.placedAt, + ).toBe(ISO) + }) +}) diff --git a/packages/stack/__tests__/dynamodb/v2-table-forwarding.test.ts b/packages/stack/__tests__/dynamodb/v2-table-forwarding.test.ts index d13ad10b7..67c7fd967 100644 --- a/packages/stack/__tests__/dynamodb/v2-table-forwarding.test.ts +++ b/packages/stack/__tests__/dynamodb/v2-table-forwarding.test.ts @@ -1,350 +1,248 @@ /** - * Which table (if any) the DynamoDB adapter forwards to the encryption client. + * Which TABLE a legacy DynamoDB read forwards, and to which client shape. * - * `encryptedDynamoDB` promises that `decryptModel` / `bulkDecryptModels` keep - * accepting an EQL **v2** table so previously stored v2 items stay readable - * (see the contract note in `src/dynamodb/index.ts`). That promise breaks if the - * adapter forwards the v2 table to a v3-configured client: the typed client - * looks the table up in its own reconstructor map, does not find it, and fails - * with "decryptModel received a table this client was not initialized with". + * This file is the SURFACE axis of the legacy read path, and it deliberately + * uses one `types.TextEq` column throughout — the forward is table-level, so a + * second domain would not make these cases stronger. The TYPE axis (what + * happens to a `date`, a `bigint`, a nested column on the way back out) lives in + * `v2-type-coverage.test.ts`, which drives the whole domain catalog through the + * real client wrapper. * - * The nominal client derives the table from the payloads and needs no second - * argument, and the typed client now exposes a table-less overload for exactly - * this case — so the correct forward is conditional on the table's generation, - * not unconditional. - * - * Credential-free by construction: the adapter never touches the AWS SDK, and - * these drive it with a recording stub client, so the assertion is on the call - * the adapter makes rather than on a live decrypt. That matters because the only - * other coverage of this path is an integration suite requiring live ZeroKMS. + * The one exception is the last case here, because on the wasm-shaped client the + * two axes meet: that entry reconstructs dates ITSELF from the forwarded table, + * so the forward is what decides whether a legacy date read is a `Date` at all. */ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { encryptedDynamoDB } from '@/dynamodb' -import { encryptedTable as encryptedTableV3, types } from '@/eql/v3' -import { encryptedColumn, encryptedTable as encryptedTableV2 } from '@/schema' -import { logger } from '@/utils/logger' - -// The audit-drop tests spy on the shared `logger` singleton, which other suites -// in this directory also patch. Each restores its own spy in a `finally`; this -// is the safety net so a patched method can never survive a test boundary. -afterEach(() => { - vi.restoreAllMocks() -}) +import { type AnyV3Table, encryptedTable, types } from '@/eql/v3' +import { DATE_LIKE_CASTS } from '@/eql/v3/columns' +import { reconstructDatePaths } from '@/eql/v3/date-reconstruction' -const usersV2 = encryptedTableV2('users_v2', { - email: encryptedColumn('email').equality(), -}) - -const usersV3 = encryptedTableV3('users_v3', { +const users = encryptedTable('users', { email: types.TextEq('email'), }) /** - * A client that records how each decrypt method was called. `getEncryptConfig` - * reports `knownTables` so the construction-time version guard sees a client - * that knows the v3 table under test. + * @param requiresTableForDecrypt models the WASM client, whose `decryptModel` / + * `bulkDecryptModels` resolve date fields from a per-table map and THROW + * without a table (`WasmEncryptionClient.requireTable`). The stub throws for + * the same input, which is what makes the wasm-shaped cases below able to + * fail: a plain recorder would pass whether or not the adapter forwarded the + * table, so asserting against one proves nothing the cases above don't. */ -function recordingClient(knownTables: string[]) { - const calls: { method: string; argCount: number; table: unknown }[] = [] - +function recordingClient(options: { requiresTableForDecrypt?: boolean } = {}) { + const calls: { method: string; args: unknown[] }[] = [] const record = (method: string) => (...args: unknown[]) => { - calls.push({ method, argCount: args.length, table: args[1] }) - return Promise.resolve({ data: {} }) + if (options.requiresTableForDecrypt && method.endsWith('DecryptModels')) { + if (args[1] === undefined) { + throw new Error( + `[wasm stub]: ${method} requires the table — this client resolves date fields from a per-table map`, + ) + } + } + if ( + options.requiresTableForDecrypt && + method === 'decryptModel' && + args[1] === undefined + ) { + throw new Error( + '[wasm stub]: decryptModel requires the table — this client resolves date fields from a per-table map', + ) + } + calls.push({ method, args }) + return Promise.resolve({ data: method.startsWith('bulk') ? [{}] : {} }) } - const client = { - getEncryptConfig: () => ({ - v: 1, - tables: Object.fromEntries(knownTables.map((t) => [t, {}])), - }), - encryptModel: record('encryptModel'), - bulkEncryptModels: record('bulkEncryptModels'), - decryptModel: record('decryptModel'), - bulkDecryptModels: record('bulkDecryptModels'), + return { + calls, + client: { + requiresTableForDecrypt: options.requiresTableForDecrypt, + getEncryptConfig: () => ({ tables: { users: {} } }), + encryptModel: record('encryptModel'), + bulkEncryptModels: record('bulkEncryptModels'), + decryptModel: record('decryptModel'), + bulkDecryptModels: record('bulkDecryptModels'), + }, } - - return { calls, client } } -describe('decryptModel table forwarding', () => { - it('does not forward an EQL v2 table to the client', async () => { - const { calls, client } = recordingClient([]) +describe('legacy DynamoDB read forwarding', () => { + it('forwards the registered v3 table while reconstructing EQL v2 storage', async () => { + const { calls, client } = recordingClient() const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) - await dynamo.decryptModel({ pk: 'a' }, usersV2) + await dynamo.decryptModel({ email__source: 'ciphertext' }, users, { + storedEqlVersion: 2, + }) expect(calls).toHaveLength(1) expect(calls[0]?.method).toBe('decryptModel') - // The v2 table must not reach the client — a typed client would reject it. - expect(calls[0]?.table).toBeUndefined() - }) - - it('still forwards an EQL v3 table, which the typed client requires', async () => { - const { calls, client } = recordingClient(['users_v3']) - const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) - - await dynamo.decryptModel({ pk: 'a' }, usersV3) - - expect(calls).toHaveLength(1) - expect(calls[0]?.table).toBe(usersV3) + expect(calls[0]?.args).toHaveLength(2) + expect(calls[0]?.args[1]).toBe(users) + expect(calls[0]?.args[0]).toEqual({ + email: { + i: { c: 'email', t: 'users' }, + v: 2, + k: 'ct', + c: 'ciphertext', + }, + }) }) -}) -describe('bulkDecryptModels table forwarding', () => { - it('does not forward an EQL v2 table to the client', async () => { - const { calls, client } = recordingClient([]) + it('forwards the registered v3 table on bulk legacy reads', async () => { + const { calls, client } = recordingClient() const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) - await dynamo.bulkDecryptModels([{ pk: 'a' }], usersV2) + await dynamo.bulkDecryptModels([{ email__source: 'ciphertext' }], users, { + storedEqlVersion: 2, + }) - expect(calls).toHaveLength(1) expect(calls[0]?.method).toBe('bulkDecryptModels') - expect(calls[0]?.table).toBeUndefined() + expect(calls[0]?.args).toHaveLength(2) + expect(calls[0]?.args[1]).toBe(users) }) - it('still forwards an EQL v3 table, which the typed client requires', async () => { - const { calls, client } = recordingClient(['users_v3']) + it('continues to forward the table for stored EQL v3 items', async () => { + const { calls, client } = recordingClient() const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) - await dynamo.bulkDecryptModels([{ pk: 'a' }], usersV3) + await dynamo.decryptModel({ email__source: 'ciphertext' }, users) - expect(calls).toHaveLength(1) - expect(calls[0]?.table).toBe(usersV3) + expect(calls[0]?.args[1]).toBe(users) }) -}) -/** - * #772 review, finding 10. - * - * The table-less v2 decrypt above is correct for the native clients, which - * derive the table from the payloads. `WasmEncryptionClient` cannot: its - * decrypt requires the table and resolves date fields from a per-table map, so - * the omitted argument reached `requireTable(undefined)` and threw a TypeError - * about reading `tableName` — a message pointing nowhere near the cause, on the - * documented entry for Deno / Workers / Supabase Edge Functions, which - * satisfies `DynamoDBEncryptionClient` structurally and so is accepted with no - * cast. - */ -describe('a client whose decrypt requires the table', () => { - /** The shape `WasmEncryptionClient` presents: declared capability, no `.audit()`. */ - function wasmShapedClient(knownTables: string[]) { - const calls: { method: string; argCount: number }[] = [] - const record = - (method: string) => - (...args: unknown[]) => { - calls.push({ method, argCount: args.length }) - // Mirrors requireTable: throws rather than returning a Result. - if (args[1] === undefined) { - throw new TypeError( - "Cannot read properties of undefined (reading 'tableName')", - ) - } - // A bare promise — NOT a thenable operation with `.audit()`. That is - // the whole point of this stub: it is the shape `WasmEncryptionClient` - // returns from `wasmResult`. The bulk methods resolve to an array, - // index-aligned with their input, as the real client does. - return Promise.resolve( - method.startsWith('bulk') ? { data: [{}] } : { data: {} }, - ) - } - const client = { + // The two cases below are the ones that would have caught the refusal this + // adapter used to carry: a wasm-shaped client was rejected outright on the + // legacy path, on the false premise that the v2 read omits the table. Their + // stub throws when called table-less, so if the adapter ever stops forwarding + // it, these fail rather than quietly recording the wrong call. + it('forwards the registered v3 table for clients that require table-aware decrypt', async () => { + const { calls, client } = recordingClient({ requiresTableForDecrypt: true, - getEncryptConfig: () => ({ - v: 1, - tables: Object.fromEntries(knownTables.map((t) => [t, {}])), - }), - encryptModel: record('encryptModel'), - bulkEncryptModels: record('bulkEncryptModels'), - decryptModel: record('decryptModel'), - bulkDecryptModels: record('bulkDecryptModels'), - } - return { calls, client } - } - - it('is refused for an EQL v2 table, naming the entry to use instead', () => { - const { calls, client } = wasmShapedClient([]) + }) const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) - // Synchronous: the guard runs when the operation is built, so the failure - // lands at the call site rather than as a rejected promise later. - expect(() => dynamo.decryptModel({ pk: 'a' }, usersV2)).toThrow( - /wasm-inline client cannot be paired with the legacy EQL v2 table/, - ) - // Refused before the client is touched, so the user never sees the - // TypeError about `tableName`. - expect(calls).toHaveLength(0) - }) - - it('is refused on the bulk v2 path too', () => { - const { client } = wasmShapedClient([]) - const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) + await dynamo.decryptModel({ email__source: 'ciphertext' }, users, { + storedEqlVersion: 2, + }) - expect(() => dynamo.bulkDecryptModels([{ pk: 'a' }], usersV2)).toThrow( - /wasm-inline client cannot be paired with the legacy EQL v2 table/, - ) - }) - - /** - * #788 review, minor finding. - * - * The guard runs on all four operations, not just the two read ones, so a - * plain-JS caller reaching the write path with a v2 table hits the SAME - * message. It must therefore not be phrased for reads only — "would fail at - * the first read" names an operation that never ran. - * - * Typed callers cannot get here (the write overloads are `AnyV3Table`-only, - * pinned by `client-compat.test-d.ts`), so this is about the message a JS - * caller or a cast lands on, not about reachable behaviour changing. - */ - it('is refused on the v2 WRITE path, with a message that does not claim a read', () => { - const { calls, client } = wasmShapedClient([]) - const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) - - for (const call of [ - () => dynamo.encryptModel({ pk: 'a' } as never, usersV2 as never), - () => dynamo.bulkEncryptModels([{ pk: 'a' }] as never, usersV2 as never), - ]) { - expect(call).toThrow( - /wasm-inline client cannot be paired with the legacy EQL v2 table/, - ) - // The read-path phrasing must not survive on a write. - expect(call).not.toThrow(/would fail at the first read/) - expect(call).not.toThrow(/cannot read legacy EQL v2 items/) - } - - expect(calls).toHaveLength(0) + expect(calls).toHaveLength(1) + expect(calls[0]?.args[1]).toBe(users) + expect(calls[0]?.args[0]).toEqual({ + email: { + i: { c: 'email', t: 'users' }, + v: 2, + k: 'ct', + c: 'ciphertext', + }, + }) }) - // v3 tables ARE forwarded the table, so this client works there — the guard - // must not turn into a blanket rejection of the wasm entry. - it('is accepted for an EQL v3 table, which is always given the table', async () => { - const { calls, client } = wasmShapedClient(['users_v3']) + it('forwards the registered v3 table on bulk legacy reads for table-aware clients', async () => { + const { calls, client } = recordingClient({ + requiresTableForDecrypt: true, + }) const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) - await dynamo.decryptModel({ pk: 'a' }, usersV3) + await dynamo.bulkDecryptModels([{ email__source: 'ciphertext' }], users, { + storedEqlVersion: 2, + }) expect(calls).toHaveLength(1) - expect(calls[0]?.argCount).toBe(2) + expect(calls[0]?.args[1]).toBe(users) }) - /** - * #788 review follow-up: the same "v3 tables are unaffected" promise, on the - * WRITE path. - * - * The encrypt operations chained `.audit()` onto the client's result - * unconditionally. The native clients return a thenable operation carrying - * it; `WasmEncryptionClient.encryptModel` returns a plain - * `Promise` from `wasmResult` (it has no `.audit()` anywhere), - * so every v3 encrypt through this adapter died with - * `client.encryptModel(...).audit is not a function` — surfaced as a - * `DYNAMODB_ENCRYPTION_ERROR` failure, not a crash, so it read as a genuine - * encryption fault. The decrypt path already tolerates the bare promise via - * `resolveDecryptResult`; the write path must match. - */ - it('encrypts an EQL v3 table even though its encrypt returns a bare promise', async () => { - const { calls, client } = wasmShapedClient(['users_v3']) + it('rejects an invalid stored version from JavaScript callers', () => { + const { client } = recordingClient() const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) - const single = await dynamo.encryptModel({ pk: 'a' } as never, usersV3) - expect(single.failure).toBeUndefined() - - const bulk = await dynamo.bulkEncryptModels([{ pk: 'a' }] as never, usersV3) - expect(bulk.failure).toBeUndefined() - - expect(calls.map((c) => c.method)).toEqual([ - 'encryptModel', - 'bulkEncryptModels', - ]) + expect(() => + dynamo.decryptModel({ email__source: 'ciphertext' }, users, { + storedEqlVersion: 4, + } as never), + ).toThrow(/unsupported storedEqlVersion 4/) + expect(() => + dynamo.bulkDecryptModels([{ email__source: 'ciphertext' }], users, { + storedEqlVersion: 4, + } as never), + ).toThrow(/unsupported storedEqlVersion 4/) }) +}) - /** - * Audit metadata has nowhere to go on this client shape, so it is dropped — - * but the encrypt must still succeed rather than failing the whole write, and - * the drop must be OBSERVABLE rather than silent. Asserting only that the - * result succeeded would pass even with the debug log deleted, and that log is - * the half that makes a missing audit record diagnosable. - */ - it('drops audit metadata observably, without failing the encrypt', async () => { - const spy = vi.spyOn(logger, 'debug').mockImplementation(() => {}) - - try { - const { client } = wasmShapedClient(['users_v3']) - const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) - - const result = await dynamo - .encryptModel({ pk: 'a' } as never, usersV3) - .audit({ metadata: { requestId: 'r-1' } }) - - expect(result.failure).toBeUndefined() - expect(spy).toHaveBeenCalledWith( - expect.stringContaining('encryptModel audit metadata ignored'), - ) - // It must name the entry to switch to, not merely report a loss. - expect(spy.mock.calls.at(-1)?.[0]).toMatch(/@cipherstash\/stack/) - } finally { - spy.mockRestore() - } +/** + * The point of forwarding the table, on the entry where it is load-bearing. + * + * The native client reconstructs `Date` columns in the wrapper + * (`rowReconstructor`, `client-v3.ts`). `WasmEncryptionClient` does it INSIDE + * its own `decryptModel`, from `dateFieldsByTable` — a per-table map keyed by + * the table it is handed. The cases above prove the adapter forwards the table + * on a legacy read; this proves what that forward buys: a `date` column stored + * as EQL v2 still comes back as a `Date`, reconstructed off the CURRENT v3 + * descriptor's `cast_as`. + * + * The stub resolves its date paths the way that client does — from the + * forwarded table — so dropping the forward makes it throw rather than silently + * hand back the FFI's string. + */ +describe('a client that reconstructs dates from the forwarded table', () => { + const people = encryptedTable('people', { + email: types.TextEq('email'), + bornOn: types.Date('born_on'), }) - /** - * The mirror: a client that DOES carry `.audit()` must not trip the drop path. - * Without this, a "fix" that logged unconditionally — or that stopped chaining - * `.audit()` at all — would leave every test green while silently discarding - * the native clients' audit trail. - */ - it('does not report a drop when the client can carry the metadata', async () => { - const spy = vi.spyOn(logger, 'debug').mockImplementation(() => {}) - - try { - let seen: unknown - const chainable = { - audit(config: { metadata?: Record }) { - seen = config.metadata - return Promise.resolve({ data: {} }) - }, - } - const client = { - getEncryptConfig: () => ({ v: 1, tables: { users_v3: {} } }), - encryptModel: () => chainable, - bulkEncryptModels: () => chainable, - decryptModel: () => Promise.resolve({ data: {} }), - bulkDecryptModels: () => Promise.resolve({ data: {} }), - } - const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) - - const result = await dynamo - .encryptModel({ pk: 'a' } as never, usersV3) - .audit({ metadata: { requestId: 'r-1' } }) - - expect(result.failure).toBeUndefined() - expect(seen).toEqual({ requestId: 'r-1' }) - expect(spy).not.toHaveBeenCalledWith( - expect.stringContaining('audit metadata ignored'), + /** The date-cast JS property paths of a table — what the wasm entry precomputes. */ + function datePathsOf(table: AnyV3Table): string[] { + const { columns } = table.build() + return Object.entries(table.buildColumnKeyMap()) + .filter(([, dbName]) => + (DATE_LIKE_CASTS as readonly string[]).includes( + String(columns[dbName]?.cast_as), + ), ) - } finally { - spy.mockRestore() - } - }) + .map(([property]) => property) + } - /** - * The tolerance must not swallow a malformed result into a fake success — - * the same guard `resolveDecryptResult` applies on read. - */ - it('rejects a bare encrypt result that is not { data } or { failure }', async () => { + it('returns a Date for a date column stored as EQL v2', async () => { const client = { - requiresTableForDecrypt: true, - getEncryptConfig: () => ({ v: 1, tables: { users_v3: {} } }), - encryptModel: () => Promise.resolve('not-a-result'), - bulkEncryptModels: () => Promise.resolve('not-a-result'), - decryptModel: () => Promise.resolve({ data: {} }), - bulkDecryptModels: () => Promise.resolve({ data: {} }), + decryptModel: (item: Record, table?: AnyV3Table) => { + if (!table) { + throw new Error( + '[wasm stub]: decryptModel requires the table — this client resolves date fields from a per-table map', + ) + } + // Stand in for the FFI: every rebuilt envelope decrypts to the string + // form protect-ffi hands back. The `Date` can then only come from the + // reconstruction the forwarded table drives. + const decrypted = Object.fromEntries( + Object.entries(item).map(([key, value]) => [ + key, + value !== null && typeof value === 'object' && 'c' in value + ? String((value as { c: unknown }).c) + : value, + ]), + ) + return Promise.resolve({ + data: reconstructDatePaths(decrypted, datePathsOf(table)), + }) + }, } + const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) - const result = await dynamo.encryptModel({ pk: 'a' } as never, usersV3) + const result = await dynamo.decryptModel( + { pk: 'p#1', bornOn__source: '1990-04-05T00:00:00.000Z' }, + people, + { storedEqlVersion: 2 }, + ) + + expect(result.failure).toBeUndefined() + if (result.failure) return - expect(result.failure?.message).toMatch(/malformed result/) + const data = result.data as Record + expect(data.bornOn).toBeInstanceOf(Date) + expect((data.bornOn as Date).toISOString()).toBe('1990-04-05T00:00:00.000Z') + expect(data.pk).toBe('p#1') }) }) diff --git a/packages/stack/__tests__/dynamodb/v2-type-coverage.test.ts b/packages/stack/__tests__/dynamodb/v2-type-coverage.test.ts new file mode 100644 index 000000000..0d4f0bd6b --- /dev/null +++ b/packages/stack/__tests__/dynamodb/v2-type-coverage.test.ts @@ -0,0 +1,794 @@ +/** + * Legacy DynamoDB reads (`storedEqlVersion: 2`) across the TYPE catalog. + * + * `v2-table-forwarding.test.ts` covers the SURFACE axis of this path — which + * table is forwarded, to which client shape, on single vs. bulk reads — and + * every case there uses one `types.TextEq` column. That leaves the question this + * file exists to answer untouched: what happens to a `date`, a `bigint`, a + * `boolean` or a nested column on the way back out of a legacy read. + * + * The question is not cosmetic, because the two halves of a legacy read disagree + * about wire version by design: + * + * - the ENVELOPE is rebuilt as v2 (`{ v: 2, k: 'ct', i, c }`) from the stored + * `__source` — `toItemWithEqlPayloads` with a v2 read context; + * - the RECONSTRUCTION is driven by the CURRENT v3 descriptor's `cast_as` + * (`DATE_LIKE_CASTS` → `rowReconstructor` → `reconstructDatePaths`) and runs + * regardless of what the payload said on the wire. + * + * ## What is faked, and why that still proves something + * + * The seam is cut at the protect-ffi boundary and nowhere above it. `ffiStub` + * stands in for the native client that talks to the FFI; it is wrapped by the + * REAL `createEncryptionClient`, and read through the REAL `encryptedDynamoDB`. + * So the envelope rebuild, the table forwarding, the registration check and the + * date reconstruction are all product code — only the crypto is stubbed. + * + * Each case therefore asserts at BOTH ends, and the two ends are different + * values, so no case can pass by echoing its own fixture: + * + * - the envelope handed DOWN to the stub is compared against the exact v2 + * payload the adapter is supposed to have rebuilt (opaque ciphertext token + * included), and + * - the model handed BACK UP is compared against the catalog's `sample` — while + * the stub only ever returns the catalog's `plaintext`, which for every + * date-like domain is an ISO STRING. A `Date` in the result can only have + * come from real reconstruction. + * + * The honest limit: this cannot say what protect-ffi returns for a v2 payload of + * a given domain — that is what + * `integration/shared/v2-decrypt-compat.integration.test.ts` mints real v2 + * fixtures to answer. What it can say, and does, is that everything between the + * FFI and the caller carries those plaintexts faithfully and reconstructs the + * date-like ones. See `bigint is carried, never repaired` below, which pins the + * consequence of that split rather than papering over it. + * + * Domain SELECTION comes from `@cipherstash/test-kit`'s `v2FixturePlan()` — the + * same plan the native and WASM v2 suites use, so the three surfaces cannot + * quietly disagree about which domains they claim. Its accounting (every catalog + * domain minted or deferred with a written reason) is asserted in + * `__tests__/test-kit-v2-fixtures.test.ts` and not repeated here. + */ +import type { V2FixtureCase } from '@cipherstash/test-kit' +import { + unwrapResult, + v2FixtureColumns, + v2FixturePlan, +} from '@cipherstash/test-kit' +import { beforeAll, describe, expect, it } from 'vitest' +import { encryptedDynamoDB } from '@/dynamodb' +import { createEncryptionClient } from '@/encryption/client-v3' +import { + type AnyV3Table, + buildEncryptConfig, + encryptedTable, + types, +} from '@/eql/v3' +import type { CastAs } from '@/schema' + +// --------------------------------------------------------------------------- +// The seam: a stub at the protect-ffi boundary, wrapped by the real client. +// --------------------------------------------------------------------------- + +/** The parameter `createEncryptionClient` wraps. Its type is not exported. */ +type UnderlyingClient = Parameters[0] + +/** + * A minimal operation stub. `createEncryptionClient` wraps the underlying + * decrypt op in a `MappedDecryptOperation` and calls `.execute()` on it, so the + * stub must be operation-like rather than a bare promise — the same shape + * `typed-client-v3.test.ts` uses. + */ +function fakeOp(result: R) { + return { + execute: () => Promise.resolve(result), + audit() { + return this + }, + withLockContext() { + return this + }, + } +} + +/** + * Stand in for the native client that calls protect-ffi. + * + * `wire` maps a fake ciphertext to the plaintext protect-ffi would hand back for + * it. Selection is STRUCTURAL — `v` + `i` + (`c` | `sv`) — which is how the FFI + * itself discriminates an encrypted field. The predicate is restated here rather + * than imported from `@/encryption/helpers` so the stub is an independent + * oracle: importing the product's detector would let a regression in it pass + * unnoticed on both sides at once. + * + * A ciphertext with no fixture THROWS. The adapter runs this inside + * `withResult`, so it surfaces as a `{ failure }` naming the envelope — which is + * what a case sees if the adapter ever stops rebuilding one. + */ +function ffiStub(options: { + wire: Record + tables: readonly AnyV3Table[] +}) { + /** Models handed DOWN to the FFI boundary, in call order. */ + const received: Array> = [] + + const decryptTree = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(decryptTree) + if (value === null || typeof value !== 'object') return value + + const payload = value as Record + const isEnvelope = + typeof payload.v === 'number' && + typeof payload.i === 'object' && + payload.i !== null && + ('c' in payload || 'sv' in payload) + + if (isEnvelope) { + // Scalars are keyed by their ciphertext; a SteVec document has no root + // `c`, so its per-document KeyHeader `h` is the handle instead. + const handle = + typeof payload.c === 'string' + ? payload.c + : typeof payload.h === 'string' + ? payload.h + : undefined + if (handle === undefined || !(handle in options.wire)) { + throw new Error( + `[ffi stub]: no fixture plaintext for envelope ${JSON.stringify(payload)}`, + ) + } + return options.wire[handle] + } + + return Object.fromEntries( + Object.entries(payload).map(([key, nested]) => [ + key, + decryptTree(nested), + ]), + ) + } + + const underlying = { + decryptModel: (input: Record) => { + received.push(input) + return fakeOp({ data: decryptTree(input) }) + }, + bulkDecryptModels: (input: Array>) => { + received.push(...input) + return fakeOp({ data: input.map(decryptTree) }) + }, + // The adapter's v3 registration guard reads this off the client. Returning a + // real config (rather than `undefined`, which the guard treats as unreadable + // and stays silent on) is what makes the v3-vs-v2 contrast below meaningful. + getEncryptConfig: () => buildEncryptConfig(...options.tables), + } + + const client = createEncryptionClient( + underlying as unknown as UnderlyingClient, + ...options.tables, + ) + + return { + received, + client, + dynamo: encryptedDynamoDB({ encryptionClient: client }), + } +} + +/** + * Assert a decrypted field against its catalog sample, keyed by the plaintext + * axis rather than by `typeof sample`, so the runtime SHAPE is pinned and not + * merely the value. Mirrors the helper in the native v2 integration suite. + * + * The `default` arm throws instead of comparing loosely: a catalog domain on a + * new `cast_as` must arrive with a deliberate assertion, not inherit a weak one + * and look covered. + */ +function expectReconstructed( + actual: unknown, + castAs: CastAs, + sample: unknown, + label: string, +): void { + switch (castAs) { + case 'date': + case 'timestamp': + // The stub returns an ISO STRING for these, so a `Date` here can only be + // the v3 descriptor's `cast_as` driving `reconstructDatePaths` over a + // payload that was v2 on the wire. + expect(actual, `${label}: expected a reconstructed Date`).toBeInstanceOf( + Date, + ) + expect(actual).toEqual(sample) + return + case 'bigint': + // Not `toEqual`: the point is the TYPE, and only an explicit typeof states + // it. `V3DecryptedModel` promises `bigint` on a legacy read too. + expect(typeof actual, `${label}: expected a native bigint`).toBe('bigint') + expect(actual).toBe(sample) + return + case 'number': + expect(typeof actual, `${label}: expected a number`).toBe('number') + expect(actual).toBe(sample) + return + case 'string': + expect(typeof actual, `${label}: expected a string`).toBe('string') + expect(actual).toBe(sample) + return + case 'boolean': + expect(typeof actual, `${label}: expected a boolean`).toBe('boolean') + expect(actual).toBe(sample) + return + default: + throw new Error( + `${label}: no reconstruction assertion for cast_as "${castAs}". ` + + 'Add one rather than letting a new plaintext axis pass unchecked.', + ) + } +} + +// --------------------------------------------------------------------------- +// The domain matrix +// --------------------------------------------------------------------------- + +const plan = v2FixturePlan() +const matrix = encryptedTable('v2_dynamodb_matrix', v2FixtureColumns(plan)) + +/** The opaque `__source` value a legacy item holds for this fixture. */ +const ciphertext = (fixture: Pick) => + `ct:${fixture.slug}#${fixture.row}` + +/** Which domains stored a `__hmac` alongside the ciphertext. */ +const hasSearchTerm = new Map( + plan.domains.map((domain) => [ + domain.slug, + domain.spec.indexes?.unique !== undefined, + ]), +) + +/** + * The stored items, as DynamoDB holds them: split attributes only, never an + * envelope. `pk` and `note` are undeclared attributes that must survive the read + * untouched. + */ +const storedRows = Array.from({ length: plan.rowCount }, (_, row) => { + const item: Record = { + pk: `item#${row}`, + note: 'passthrough', + } + for (const fixture of plan.cases) { + if (fixture.row !== row) continue + item[`${fixture.slug}__source`] = ciphertext(fixture) + if (hasSearchTerm.get(fixture.slug)) { + item[`${fixture.slug}__hmac`] = `hmac:${fixture.slug}#${row}` + } + } + return item +}) + +const wire = Object.fromEntries( + plan.cases.map((fixture) => [ciphertext(fixture), fixture.plaintext]), +) + +// Flattened to one assertion per (domain, sample), labelled so vitest names the +// exact domain and sample index that failed. +const matrixCases = plan.cases.map( + (fixture) => + [ + fixture.label, + fixture.slug, + fixture.castAs, + fixture.sample, + fixture.row, + ] as const, +) + +describe('a legacy DynamoDB read reconstructs every plaintext axis', () => { + let handedToFfi: Array> + let decrypted: Array> + let singleRow: Record + + beforeAll(async () => { + const bulk = ffiStub({ wire, tables: [matrix] }) + decrypted = unwrapResult( + await bulk.dynamo.bulkDecryptModels(storedRows, matrix, { + storedEqlVersion: 2, + }), + ) + handedToFfi = bulk.received + + const single = ffiStub({ wire, tables: [matrix] }) + singleRow = unwrapResult( + await single.dynamo.decryptModel(storedRows[0], matrix, { + storedEqlVersion: 2, + }), + ) + }) + + /** + * The matrix must actually have been driven. An empty plan — or one whose + * cases stopped reaching this file — leaves every `it.each` below silently + * ABSENT rather than red, which is the one failure mode a green run cannot + * show. The axis list itself is pinned once, repo-wide, in + * `__tests__/test-kit-v2-fixtures.test.ts`; asserted here is only that this + * suite drives all of it, and that the axes needing runtime repair are among + * them (`json` is absent by declaration — no v2 ste_vec fixture can be minted + * — and its adapter half is pinned separately below). + */ + it('drives every case the v2 fixture plan produces', () => { + expect(plan.domains.length).toBeGreaterThan(0) + expect(matrixCases.length).toBe(plan.cases.length) + expect(handedToFfi).toHaveLength(plan.rowCount) + + const axes = new Set(plan.cases.map((fixture) => fixture.castAs)) + for (const axis of ['date', 'timestamp', 'bigint'] as const) { + expect(axes.has(axis), `${axis} left this suite's matrix`).toBe(true) + } + }) + + it.each( + matrixCases, + )('%s: rebuilds a v2 envelope and reconstructs the plaintext', (label, slug, castAs, sample, row) => { + // THE guard for this file. Rebuilt as v2 — `k: 'ct'` and `v: 2` — around + // the CURRENT v3 descriptor's identifier, carrying the stored ciphertext + // untouched. Drop the version branch and this goes red per domain. + expect(handedToFfi[row]?.[slug]).toEqual({ + i: { c: slug, t: matrix.tableName }, + v: 2, + k: 'ct', + c: ciphertext({ slug, row }), + }) + + expectReconstructed(decrypted[row]?.[slug], castAs, sample, label) + }) + + /** + * `decryptModel` and `bulkDecryptModels` are separate wrappers over the same + * reconstructor, and only the bulk one is exercised above. A row's worth of + * domains through the single path catches the wrapper being wired up without + * its `map`. + */ + it('reconstructs the same values on the single-model read', () => { + const firstRow = plan.cases.filter((fixture) => fixture.row === 0) + expect(firstRow.length).toBe(plan.domains.length) + for (const fixture of firstRow) { + expectReconstructed( + singleRow[fixture.slug], + fixture.castAs, + fixture.sample, + `${fixture.label} (single model)`, + ) + } + }) + + it('hands the FFI a model with no storage attributes left on it', () => { + const keys = Object.keys(handedToFfi[0] ?? {}) + expect(keys.filter((key) => key.endsWith('__source'))).toEqual([]) + // `__hmac` is a query term, not data: it must not reach the FFI as a field + // to decrypt, and must not reach the caller as part of the model. + expect(keys.filter((key) => key.endsWith('__hmac'))).toEqual([]) + expect([...hasSearchTerm.values()]).toContain(true) + }) + + it('passes undeclared attributes through both ways', () => { + expect(handedToFfi[0]?.pk).toBe('item#0') + expect(decrypted[0]?.pk).toBe('item#0') + expect(decrypted[0]?.note).toBe('passthrough') + }) +}) + +// --------------------------------------------------------------------------- +// Nested (dotted-path) columns +// --------------------------------------------------------------------------- + +/** + * A nested date read from a stored v2 item is the sharpest case on this path: + * `reconstructDatePaths` is path-aware (it walks segments and rebuilds nested + * objects without mutating the input), and the path it walks comes from the v3 + * descriptor while the envelope around it is v2. + */ +describe('nested columns on a legacy read', () => { + const people = encryptedTable('people', { + 'profile.birthday': types.Date('profile_birthday'), + 'profile.seenAt': types.Timestamp('profile_seen_at'), + 'profile.name': types.TextEq('profile_name'), + }) + + const stored = { + pk: 'person#1', + profile: { + birthday__source: 'ct:birthday', + seenAt__source: 'ct:seen-at', + name__source: 'ct:name', + name__hmac: 'hmac:name', + nickname: 'ada', + }, + } + + const nestedWire = { + 'ct:birthday': '1990-04-05T00:00:00.000Z', + 'ct:seen-at': '2026-07-01T12:34:56.000Z', + 'ct:name': 'Ada Lovelace', + } + + it('rebuilds a v2 envelope at the declared dotted path', async () => { + const { dynamo, received } = ffiStub({ + wire: nestedWire, + tables: [people], + }) + + await dynamo.decryptModel(stored, people, { storedEqlVersion: 2 }) + + // Matched by dotted path, identified by DB column name — the two differ + // here (`profile.birthday` vs `profile_birthday`), which is exactly where a + // read path that matched on one and identified with the other would break. + expect(received[0]).toEqual({ + pk: 'person#1', + profile: { + birthday: { + i: { c: 'profile_birthday', t: 'people' }, + v: 2, + k: 'ct', + c: 'ct:birthday', + }, + seenAt: { + i: { c: 'profile_seen_at', t: 'people' }, + v: 2, + k: 'ct', + c: 'ct:seen-at', + }, + name: { + i: { c: 'profile_name', t: 'people' }, + v: 2, + k: 'ct', + c: 'ct:name', + }, + nickname: 'ada', + }, + }) + }) + + it('reconstructs a nested date and timestamp from a stored v2 item', async () => { + const { dynamo } = ffiStub({ wire: nestedWire, tables: [people] }) + + const decrypted = unwrapResult( + await dynamo.decryptModel(stored, people, { storedEqlVersion: 2 }), + ) + const profile = (decrypted as { profile: Record }).profile + + expect(profile.birthday).toBeInstanceOf(Date) + expect(profile.birthday).toEqual(new Date('1990-04-05T00:00:00.000Z')) + // `timestamp` keeps the time of day; a truncating regression would leave + // midnight here and still be a `Date`. + expect(profile.seenAt).toBeInstanceOf(Date) + expect((profile.seenAt as Date).toISOString()).toBe( + '2026-07-01T12:34:56.000Z', + ) + expect(profile.name).toBe('Ada Lovelace') + // Siblings survive; the query term does not. + expect(profile.nickname).toBe('ada') + expect(profile).not.toHaveProperty('name__hmac') + }) + + it('reconstructs nested dates on the bulk read too', async () => { + const { dynamo } = ffiStub({ wire: nestedWire, tables: [people] }) + + const rows = unwrapResult( + await dynamo.bulkDecryptModels([stored, stored], people, { + storedEqlVersion: 2, + }), + ) + + expect(rows).toHaveLength(2) + for (const row of rows) { + const profile = (row as { profile: Record }).profile + expect(profile.birthday).toBeInstanceOf(Date) + expect(profile.seenAt).toBeInstanceOf(Date) + } + }) +}) + +/** + * The gap this file found, now closed. + * + * A v2 GROUPED column registered its build key on the bare leaf, so a field + * inside a group was stored as `.__source` while the schema knew it + * only as ``. `makeColumnMatcher`'s bare-leaf fallback (v2 reads only) + * exists precisely so those attributes still rebuild — see the + * `stored EQL v2 grouped fields` block in `helpers-v3.test.ts`. + * + * The envelope is rebuilt at the NESTED position (`details.birthday`), but the + * clients key date reconstruction on the DECLARED path (`birthday`) — native via + * `rowReconstructor`, WASM via `dateFields` — so neither found it and the value + * came back as the FFI's string. The read path now reports the actual path it + * wrote to and the adapter reconstructs there, so a grouped date carries forward + * as a `Date` without the caller having to re-declare it as a dotted path. + */ +describe('a v2 grouped date column reconstructs at its nested path', () => { + const orders = encryptedTable('orders', { + birthday: types.Date('birthday'), + label: types.Text('label'), + }) + + it('rebuilds the envelope under the group and reconstructs the date', async () => { + const { dynamo, received } = ffiStub({ + wire: { 'ct:grouped': '1990-04-05T00:00:00.000Z' }, + tables: [orders], + }) + + const decrypted = unwrapResult( + await dynamo.decryptModel( + { details: { birthday__source: 'ct:grouped' } }, + orders, + { storedEqlVersion: 2 }, + ), + ) + + // The bare-leaf fallback did its job: the value decrypts. + expect(received[0]).toEqual({ + details: { + birthday: { + i: { c: 'birthday', t: 'orders' }, + v: 2, + k: 'ct', + c: 'ct:grouped', + }, + }, + }) + const details = (decrypted as { details: Record }).details + expect(details.birthday).toBeInstanceOf(Date) + expect(details.birthday).toEqual(new Date('1990-04-05T00:00:00.000Z')) + }) + + it('reconstructs a grouped date on the bulk read too', async () => { + const { dynamo } = ffiStub({ + wire: { 'ct:grouped': '1990-04-05T00:00:00.000Z' }, + tables: [orders], + }) + + const rows = unwrapResult( + await dynamo.bulkDecryptModels( + [ + { details: { birthday__source: 'ct:grouped' } }, + { details: { birthday__source: 'ct:grouped' } }, + ], + orders, + { storedEqlVersion: 2 }, + ), + ) + + for (const row of rows) { + const details = (row as { details: Record }).details + expect(details.birthday).toBeInstanceOf(Date) + } + }) + + /** + * A date-shaped STRING at a grouped non-date column must stay a string. The + * reconstruction is driven by the column's `cast_as`, not by whether the value + * happens to parse as a date, and this is the case that tells the two apart. + */ + it('leaves a grouped text column alone even when its value parses as a date', async () => { + const { dynamo } = ffiStub({ + wire: { 'ct:label': '1990-04-05T00:00:00.000Z' }, + tables: [orders], + }) + + const decrypted = unwrapResult( + await dynamo.decryptModel( + { details: { label__source: 'ct:label' } }, + orders, + { storedEqlVersion: 2 }, + ), + ) + + const details = (decrypted as { details: Record }).details + expect(details.label).toBe('1990-04-05T00:00:00.000Z') + expect(details.label).not.toBeInstanceOf(Date) + }) +}) + +// --------------------------------------------------------------------------- +// JSON (ste_vec) documents +// --------------------------------------------------------------------------- + +/** + * The `json` axis, as far as it can be taken. + * + * No v2 JSON fixture can be minted at all — cipherstash-client 0.42 refuses to + * emit a ste_vec ciphertext in EQL v2 mode, which is why `V2_MINT_DEFERRED` + * excludes the domain and `V2_UNREACHABLE_CAST_AS` declares the axis + * unreachable. Legacy v2 documents already on disk remain decryptable, so the + * ADAPTER half is still worth pinning: given such an item, the envelope it + * rebuilds must be a well-formed SteVec one carrying the stored `k`, `h` and + * `sv`, and the document must reach the caller untouched (no date-like cast, so + * no reconstruction). + */ +describe('a stored v2 JSON document', () => { + const docs = encryptedTable('docs', { meta: types.Json('meta') }) + const entries = [{ s: 'sel', c: 'ct', a: false, hm: 'h' }] + const document = { user: 'ada@example.com', roles: ['admin', 'eng'] } + + it('rebuilds the SteVec envelope with v: 2 and returns the document', async () => { + const { dynamo, received } = ffiStub({ + wire: { 'key-header': document }, + tables: [docs], + }) + + const decrypted = unwrapResult( + await dynamo.decryptModel( + { pk: 'doc#1', meta__source: { h: 'key-header', sv: entries } }, + docs, + { storedEqlVersion: 2 }, + ), + ) + + // `k: 'sv'` and the per-document KeyHeader `h` are mandatory — protect-ffi + // 0.30 deserialization fails without either, and there is no root `c` to + // fall back on. + expect(received[0]).toEqual({ + pk: 'doc#1', + meta: { + i: { c: 'meta', t: 'docs' }, + v: 2, + k: 'sv', + h: 'key-header', + sv: entries, + }, + }) + expect(decrypted).toEqual({ pk: 'doc#1', meta: document }) + }) +}) + +// --------------------------------------------------------------------------- +// bigint +// --------------------------------------------------------------------------- + +/** + * `PlaintextFromKind` promises `bigint`, and the table-aware overload returns + * `V3DecryptedModel` — so the TYPE says `bigint` on a legacy read + * exactly as it does on a v3 one. The matrix above asserts `typeof === 'bigint'` + * for every bigint domain, which proves this path does not degrade a native + * bigint. + * + * What it cannot prove is the other half: reconstruction is DATE-ONLY + * (`rowReconstructor` filters on `DATE_LIKE_CASTS`; its docblock notes bigint + * "needs none — protect-ffi returns a native JS bigint on decrypt"). If a v2 + * int8 payload ever decrypted to a string, nothing between the FFI and the + * caller would convert it, and the type would be lying. Whether it does is a + * protect-ffi question, answered by the live v2 matrix in + * `integration/shared/v2-decrypt-compat.integration.test.ts`. + * + * This case pins the consequence so the division of labour is visible rather + * than assumed: the adapter path adds no repair, so the type's promise rests + * entirely on the FFI. + */ +describe('bigint is carried, never repaired', () => { + const ledger = encryptedTable('ledger', { + balance: types.BigintEq('balance'), + at: types.Date('at'), + }) + + it('carries a native bigint through the legacy read unchanged', async () => { + const { dynamo } = ffiStub({ + wire: { 'ct:balance': 9223372036854775807n }, + tables: [ledger], + }) + + const decrypted = unwrapResult( + await dynamo.decryptModel({ balance__source: 'ct:balance' }, ledger, { + storedEqlVersion: 2, + }), + ) + + expect(typeof (decrypted as { balance: unknown }).balance).toBe('bigint') + expect((decrypted as { balance: unknown }).balance).toBe( + 9223372036854775807n, + ) + }) + + it('does not coerce a bigint the FFI hands back as a string', async () => { + const { dynamo } = ffiStub({ + wire: { 'ct:balance': '9223372036854775807', 'ct:at': '2026-07-01' }, + tables: [ledger], + }) + + const decrypted = unwrapResult( + await dynamo.decryptModel( + { balance__source: 'ct:balance', at__source: 'ct:at' }, + ledger, + { storedEqlVersion: 2 }, + ), + ) + + // The date-like column beside it IS repaired, from the same descriptor and + // the same read — so this is a deliberate scope, not a broken reconstructor. + expect((decrypted as { at: unknown }).at).toBeInstanceOf(Date) + expect(typeof (decrypted as { balance: unknown }).balance).toBe('string') + }) +}) + +// --------------------------------------------------------------------------- +// The refusals a legacy read keeps +// --------------------------------------------------------------------------- + +describe('legacy reads still refuse what they always refused', () => { + const registered = encryptedTable('registered', { + email: types.TextEq('email'), + }) + const unregistered = encryptedTable('unregistered', { + email: types.TextEq('email'), + }) + const stored = { email__source: 'ct:email' } + const wireMap = { 'ct:email': 'ada@example.com' } + + it('reads the registered table on the legacy path', async () => { + const { dynamo } = ffiStub({ wire: wireMap, tables: [registered] }) + + expect( + unwrapResult( + await dynamo.decryptModel(stored, registered, { storedEqlVersion: 2 }), + ), + ).toEqual({ email: 'ada@example.com' }) + }) + + /** + * The boundary `v2-decrypt-compat.integration.test.ts` pins live, reproduced + * here without credentials. `assertClientTableVersionMatch` early-returns for + * `storedEqlVersion: 2` — a v2 payload proves nothing about the client's v3 + * tables — but the adapter still forwards the table into the TABLE-AWARE + * `decryptModel` overload (deliberately, to preserve Date reconstruction), and + * that overload rejects a table outside the client's schema tuple. So a legacy + * DynamoDB read does require the table to be declared in + * `Encryption({ schemas })`, unlike the client's table-less reads. + */ + it('fails with a clear error when the table was never registered', async () => { + const { dynamo } = ffiStub({ wire: wireMap, tables: [registered] }) + + const result = await dynamo.decryptModel(stored, unregistered, { + storedEqlVersion: 2, + }) + + expect(result.failure?.message).toMatch(/was not initialized with/) + // The failure arm carries no `data` at all — not an empty model that a + // caller could mistake for a row with every encrypted field missing. + expect('data' in result).toBe(false) + + const bulk = await dynamo.bulkDecryptModels([stored], unregistered, { + storedEqlVersion: 2, + }) + expect(bulk.failure?.message).toMatch(/was not initialized with/) + }) + + /** + * The contrast that shows the early return is real: the SAME unregistered + * table on a v3 read is rejected earlier and louder, by the adapter's own + * guard, synchronously — before any client call. + */ + it('rejects the same table earlier on a v3 read', () => { + const { dynamo } = ffiStub({ wire: wireMap, tables: [registered] }) + + expect(() => dynamo.decryptModel(stored, unregistered)).toThrow( + /EQL version mismatch/, + ) + }) + + it('rejects an unsupported stored version before touching the client', () => { + const { dynamo, received } = ffiStub({ + wire: wireMap, + tables: [registered], + }) + + expect(() => + dynamo.decryptModel(stored, registered, { + storedEqlVersion: 4, + } as never), + ).toThrow(/unsupported storedEqlVersion 4/) + expect(() => + dynamo.bulkDecryptModels([stored], registered, { + storedEqlVersion: 4, + } as never), + ).toThrow(/unsupported storedEqlVersion 4/) + expect(received).toEqual([]) + }) +}) diff --git a/packages/stack/__tests__/empty-schemas-boundary.test.ts b/packages/stack/__tests__/empty-schemas-boundary.test.ts index 7d67ab2b2..c9226b10a 100644 --- a/packages/stack/__tests__/empty-schemas-boundary.test.ts +++ b/packages/stack/__tests__/empty-schemas-boundary.test.ts @@ -15,13 +15,14 @@ * that boundary, and this pins the runtime half of it: the guard must reject an * empty set BEFORE any FFI client is constructed, on every entry point. * - * The compile-time half is pinned in `encryption-overloads.test-d.ts`. + * The compile-time half is pinned in `encryption-v3-only.test-d.ts` + * (`encryption-overloads.test-d.ts` was deleted with the v2 authoring surface in + * `a3830f0d`; that coverage moved). */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { AnyV3Table } from '@/eql/v3' +import { type AnyV3Table, encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' -import { encryptedColumn, encryptedTable } from '@/schema' vi.mock('@cipherstash/protect-ffi', () => ({ newClient: vi.fn(async () => ({ __mock: 'client' })), @@ -69,9 +70,7 @@ describe('empty schema sets are refused at runtime', () => { ).rejects.toThrow(EMPTY_SCHEMAS) }) - // The v2 builders reach the same guard — it predates the v3 typed client and - // is not part of the overload machinery. - it('rejects an empty v2 schema set', async () => { + it('rejects an empty concretely typed schema set', async () => { const v2: Array> = [] await expect(Encryption({ schemas: v2 })).rejects.toThrow(EMPTY_SCHEMAS) @@ -90,8 +89,8 @@ describe('empty schema sets are refused at runtime', () => { const shared: AnyV3Table[] = [] shared.push( encryptedTable('users', { - email: encryptedColumn('email'), - }) as unknown as AnyV3Table, + email: types.Text('email'), + }), ) await expect(Encryption({ schemas: shared })).resolves.toBeDefined() diff --git a/packages/stack/__tests__/encrypt-lock-context-guards.test.ts b/packages/stack/__tests__/encrypt-lock-context-guards.test.ts index 2162b359a..feaa31a13 100644 --- a/packages/stack/__tests__/encrypt-lock-context-guards.test.ts +++ b/packages/stack/__tests__/encrypt-lock-context-guards.test.ts @@ -20,11 +20,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { EncryptionClient } from '@/encryption' -import type { EncryptionClientFor } from '@/encryption/v3' import { encryptedTable as encryptedTableV3, types } from '@/eql/v3' import { LockContext } from '@/identity' import { Encryption } from '@/index' -import { encryptedColumn, encryptedTable } from '@/schema' vi.mock('@cipherstash/protect-ffi', () => ({ // `getErrorCode` does `error instanceof ProtectError` on the failure path, @@ -43,8 +41,8 @@ vi.mock('@cipherstash/protect-ffi', () => ({ import * as ffi from '@cipherstash/protect-ffi' -const users = encryptedTable('users', { - score: encryptedColumn('score').dataType('number').equality().orderAndRange(), +const users = encryptedTableV3('users', { + score: types.IntegerOrd('score'), }) const usersV3 = encryptedTableV3('users_v3', { @@ -55,15 +53,14 @@ const usersV3 = encryptedTableV3('users_v3', { // biome-ignore lint/suspicious/noExplicitAny: test helper reads the Result union const failure = (result: any) => result.failure -let clientV2: EncryptionClient -let clientV3: EncryptionClientFor +let clientV2: EncryptionClient +let clientV3: EncryptionClient beforeEach(async () => { vi.clearAllMocks() process.env.CS_WORKSPACE_CRN = 'crn:ap-southeast-2.aws:test-workspace' - // One client per wire format: `Encryption` rejects mixed v2 + v3 schema - // sets (one client emits exactly one wire format), so the two schema - // styles get their own clients and the suites below pick the right one. + // Use two v3 tables to ensure the shared operation guards are independent of + // the particular concrete domain descriptor. clientV2 = await Encryption({ schemas: [users] }) clientV3 = await Encryption({ schemas: [usersV3] }) }) diff --git a/packages/stack/__tests__/encrypt-query-match-preflight.test.ts b/packages/stack/__tests__/encrypt-query-match-preflight.test.ts index 4c673e501..892761be6 100644 --- a/packages/stack/__tests__/encrypt-query-match-preflight.test.ts +++ b/packages/stack/__tests__/encrypt-query-match-preflight.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { EncryptionV3 } from '@/encryption/v3' import { encryptedTable, types } from '@/eql/v3' +import { Encryption } from '@/index' vi.mock('@cipherstash/protect-ffi', () => ({ ProtectError: class ProtectError extends Error {}, @@ -30,7 +30,7 @@ afterEach(() => { describe('core v3 match-needle preflight', () => { it('rejects a short scalar needle without calling protect-ffi', async () => { - const client = await EncryptionV3({ schemas: [users] }) + const client = await Encryption({ schemas: [users] }) const result = await client.encryptQuery('ad', { column: users.bio, table: users, @@ -42,7 +42,7 @@ describe('core v3 match-needle preflight', () => { }) it('rejects a short term in a batch before calling protect-ffi', async () => { - const client = await EncryptionV3({ schemas: [users] }) + const client = await Encryption({ schemas: [users] }) const result = await client.encryptQuery([ { value: 'ad', diff --git a/packages/stack/__tests__/encrypt-query.test.ts b/packages/stack/__tests__/encrypt-query.test.ts index 5dfa9c4b7..89008b7b3 100644 --- a/packages/stack/__tests__/encrypt-query.test.ts +++ b/packages/stack/__tests__/encrypt-query.test.ts @@ -9,11 +9,12 @@ import { expectFailure, metadata, products, + skipWithoutLiveCredentials, unwrapResult, users, } from './fixtures' -describe('encryptQuery', () => { +describe.skipIf(skipWithoutLiveCredentials)('encryptQuery', () => { let protectClient: EncryptionClient beforeAll(async () => { @@ -34,7 +35,7 @@ describe('encryptQuery', () => { expect(data).toMatchObject({ i: { t: 'users', c: 'email' }, - v: 2, + v: 3, }) expect(data).toHaveProperty('hm') }, 30000) @@ -50,7 +51,7 @@ describe('encryptQuery', () => { expect(data).toMatchObject({ i: { t: 'users', c: 'bio' }, - v: 2, + v: 3, }) expect(data).toHaveProperty('bf') }, 30000) @@ -66,9 +67,46 @@ describe('encryptQuery', () => { expect(data).toMatchObject({ i: { t: 'users', c: 'age' }, - v: 2, + v: 3, }) + expect(data).toHaveProperty('op') + }, 30000) + + it('answers equality through the ordering term on an order-capable column', async () => { + // A v3 numeric `_ord` domain carries no `hm`. Equality resolves to the + // same CLLW-OPE term `orderAndRange` emits — the two are distinguished by + // the SQL comparison operator (`=` vs `>=`), not by the ciphertext — so + // the default `equality → unique` mapping would wrongly reject it. + // Asserting `hm` is ABSENT is the load-bearing half: it fails if a future + // change gives the domain a `unique` index and quietly routes equality + // back through the HMAC. + const result = await protectClient.encryptQuery(25, { + column: users.age, + table: users, + queryType: 'equality', + }) + + const data = unwrapResult(result) + expect(data).toHaveProperty('op') + expect(data).not.toHaveProperty('hm') + }, 30000) + + it('emits the block-ORE term on an _ord_ore domain', async () => { + // The other v3 ordering flavour: `_ord_ore` domains stay block-ORE (`ore` + // index, `ob` term) where `_ord` is CLLW-OPE. This is the explicit- + // queryType path, where `queryTypeToFfi` maps orderAndRange to a static + // `ore` and `resolveIndexType` then swaps to `ope` only when the column + // lacks `ore` — so this is the case where that swap must NOT fire. The + // auto-inference test below never reaches that branch. + const result = await protectClient.encryptQuery(99.99, { + column: products.price, + table: products, + queryType: 'orderAndRange', + }) + + const data = unwrapResult(result) expect(data).toHaveProperty('ob') + expect(data).not.toHaveProperty('op') }, 30000) }) @@ -163,10 +201,14 @@ describe('encryptQuery', () => { }, 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 + // A match-only column genuinely cannot answer equality: it has no + // `unique` index and no ordering index to resolve through. (An + // order-capable column DOES answer equality — see "answers equality + // through the ordering term" above — so it can't carry this assertion.) + const result = await protectClient.encryptQuery('anything', { + column: articles.content, + table: articles, + queryType: 'equality', }) expectFailure(result, 'unique') @@ -217,7 +259,7 @@ describe('encryptQuery', () => { expect(data).toHaveProperty('bf') // bloom filter }, 30000) - it('allows number with ore index', async () => { + it('allows number with an ordering index', async () => { const result = await protectClient.encryptQuery(42, { column: users.age, table: users, @@ -225,7 +267,7 @@ describe('encryptQuery', () => { }) const data = unwrapResult(result) - expect(data).toHaveProperty('ob') // order bits + expect(data).toHaveProperty('op') // CLLW-OPE ordering term }, 30000) }) @@ -240,9 +282,9 @@ describe('encryptQuery', () => { const data = unwrapResult(result) expect(data).toMatchObject({ i: { t: 'users', c: 'age' }, - v: 2, + v: 3, }) - expect(data).toHaveProperty('ob') + expect(data).toHaveProperty('op') }, 30000) it('encrypts MIN_SAFE_INTEGER', async () => { @@ -255,9 +297,9 @@ describe('encryptQuery', () => { const data = unwrapResult(result) expect(data).toMatchObject({ i: { t: 'users', c: 'age' }, - v: 2, + v: 3, }) - expect(data).toHaveProperty('ob') + expect(data).toHaveProperty('op') }, 30000) it('encrypts negative zero', async () => { @@ -268,7 +310,7 @@ describe('encryptQuery', () => { }) const data = unwrapResult(result) - expect(data).toHaveProperty('ob') + expect(data).toHaveProperty('op') }, 30000) }) @@ -283,7 +325,7 @@ describe('encryptQuery', () => { const data = unwrapResult(result) expect(data).toMatchObject({ i: { t: 'users', c: 'email' }, - v: 2, + v: 3, }) expect(data).toHaveProperty('hm') }, 30000) @@ -298,7 +340,7 @@ describe('encryptQuery', () => { const data = unwrapResult(result) expect(data).toMatchObject({ i: { t: 'users', c: 'bio' }, - v: 2, + v: 3, }) expect(data).toHaveProperty('bf') }, 30000) @@ -316,7 +358,7 @@ describe('encryptQuery', () => { const data = unwrapResult(result) expect(data).toMatchObject({ i: { t: 'users', c: 'email' }, - v: 2, + v: 3, }) expect(data).toHaveProperty('hm') }, 30000) @@ -371,7 +413,7 @@ describe('encryptQuery', () => { expect(data).toHaveLength(2) expect(data[0]).toHaveProperty('hm') - expect(data[1]).toHaveProperty('ob') + expect(data[1]).toHaveProperty('op') }, 30000) it('rejects NaN/Infinity values in batch', async () => { @@ -473,7 +515,7 @@ describe('encryptQuery', () => { expect(data).toHaveLength(1) expect(data[0]).toMatchObject({ i: { t: 'users', c: 'email' }, - v: 2, + v: 3, }) expect(typeof data[0]).toBe('object') }, 30000) @@ -532,7 +574,7 @@ describe('encryptQuery', () => { expect(data).toHaveLength(1) expect(data[0]).toMatchObject({ i: { t: 'users', c: 'email' }, - v: 2, + v: 3, }) expect(typeof data[0]).toBe('object') }, 30000) @@ -591,7 +633,7 @@ describe('encryptQuery', () => { expect(data).toMatchObject({ i: { t: 'users', c: 'email' }, - v: 2, + v: 3, }) expect(typeof data).toBe('object') }, 30000) @@ -638,12 +680,35 @@ describe('encryptQuery', () => { expect(data).toMatchObject({ i: { t: 'users', c: 'email' }, - v: 2, + v: 3, }) expect(typeof data).toBe('object') }, 30000) }) + // These cover the SDK surface only — that `.withLockContext()` returns an + // executable operation. They deliberately do NOT execute. + // + // Two tests that DID execute were removed (see below). A lock context binds a + // data key to an end user's identity claim, which requires an + // `OidcFederationStrategy`-authenticated client — `skills/stash-encryption` + // states that a service credential "cannot be used with a lock context". This + // suite builds its client from the `CS_*` service credentials, so the claim + // could never resolve, and ZeroKMS rejects the request on the EQL v3 wire. + // + // They were never meaningful. Introduced in fea303d0, they passed against the + // live service while forwarding a literal `accessToken: 'mock-token'` — proof + // that nothing was authorising the binding. Their assertions (`i`, `v`, `hm`, + // `op`) are identical to the non-lock-context tests above, so they could not + // distinguish a bound claim from an ignored one. And per 8d707cc9, query terms + // are not identity-bound at all: the `hm` term is workspace-scoped and matches + // with or without a lock context — the identity boundary is enforced at + // DECRYPT. + // + // Real coverage lives where the claim can actually resolve, under + // CLERK_MACHINE_TOKEN + OidcFederationStrategy: + // packages/stack/integration/identity/matrix-identity.integration.test.ts + // packages/stack-drizzle/integration/lock-context.integration.test.ts describe('LockContext support', () => { it('single query with a lock context builds an executable operation', async () => { const operation = protectClient.encryptQuery('test@example.com', { @@ -671,48 +736,5 @@ describe('encryptQuery', () => { expect(withContext).toHaveProperty('execute') expect(typeof withContext.execute).toBe('function') }, 30000) - - it('executes a single query bound to an identity claim', async () => { - const operation = protectClient.encryptQuery('test@example.com', { - column: users.email, - table: users, - queryType: 'equality', - }) - - const withContext = operation.withLockContext(createMockLockContext()) - const result = await withContext.execute() - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'users', c: 'email' }, - v: 2, - }) - expect(data).toHaveProperty('hm') - }, 30000) - - it('executes a bulk query bound to an identity claim', async () => { - 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(createMockLockContext()) - const result = await withContext.execute() - - const data = unwrapResult(result) - expect(data).toHaveLength(2) - expect(data[0]).toHaveProperty('hm') - expect(data[1]).toHaveProperty('ob') - }, 30000) }) }) diff --git a/packages/stack/__tests__/encryption-overloads.test-d.ts b/packages/stack/__tests__/encryption-overloads.test-d.ts deleted file mode 100644 index aae0fc789..000000000 --- a/packages/stack/__tests__/encryption-overloads.test-d.ts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * Type-level contract for the `Encryption` overload pair. - * - * `Encryption` is overloaded — an all-v3 schema tuple yields the typed client, - * everything else yields the nominal one — and the two are NOT mutually - * assignable. Overload selection is therefore load-bearing public API, and none - * of it is exercised by a runtime test. Each case below was a real defect found - * in review (#772): a call that type-checked as the typed client but returned - * the nominal one at runtime, a schema set the types accepted and the runtime - * threw on, and a `ReturnType` idiom that silently resolves to the wrong client. - */ -import { describe, expectTypeOf, it } from 'vitest' -import { Encryption, type EncryptionClient } from '@/encryption' -import { - type EncryptionClientFor, - encryptedTable, - type TypedEncryptionClient, -} from '@/encryption/v3' -import { type AnyV3Table, types } from '@/eql/v3' -import { encryptedColumn, encryptedTable as encryptedTableV2 } from '@/schema' - -const users = encryptedTable('users', { - email: types.TextEq('email'), - createdAt: types.TimestampOrd('created_at'), -}) - -const usersV2 = encryptedTableV2('users_v2', { - email: encryptedColumn('email').equality(), -}) - -describe('overload selection', () => { - it('an all-v3 schema tuple yields the typed client', async () => { - const client = await Encryption({ schemas: [users] }) - expectTypeOf(client).toEqualTypeOf< - TypedEncryptionClient - >() - }) - - it('a v2 schema set yields the nominal client', async () => { - const client = await Encryption({ schemas: [usersV2] }) - expectTypeOf(client).toEqualTypeOf() - }) - - // S-6: `readonly []` satisfies `readonly AnyV3Table[]`, so an empty schema set - // used to compile and then throw at runtime. Both overloads now require at - // least one table. - it('rejects an empty schema set', () => { - // @ts-expect-error - at least one encryptedTable is required - Encryption({ schemas: [] }) - }) - - // ...but only when the ARGUMENT'S TYPE has a statically known length of 0. - // `NonEmptyV3` keys off `S['length'] extends 0`, so once the type widens to - // `AnyV3Table[]` the length is `number` and emptiness stops being visible — - // including for a literal at the call site, because a spread erases the tuple. - // These compile ON PURPOSE: rejecting them is what the A-4 tuple constraint - // did, and it broke every non-literal caller. The runtime guard is what - // catches them; `empty-schemas-boundary.test.ts` pins that half, and - // `skills/stash-encryption` documents the split for users. - // A generic passthrough is the shape `EncryptionClientFor` and the skill both - // advertise ("code that is generic over its schemas"), and it is the one form - // a WIDENING change must not break. It compiled before A-4 and must still. - // - // A deferred conditional on the property is what broke it: `S` is assignable - // to `NonEmptyV3` only if it is assignable to BOTH branches, and one branch - // is `never`. The emptiness rejection does not need it — overload 2's - // `AtLeastOneCsTable` rejects `[]` on its own. - it('accepts schemas from a generic wrapper function', async () => { - async function makeTypedClient< - S extends readonly [AnyV3Table, ...AnyV3Table[]], - >(schemas: S) { - return await Encryption({ schemas }) - } - - expectTypeOf(await makeTypedClient([users])).toEqualTypeOf< - TypedEncryptionClient - >() - - // A wrapper generic over a LOOSE `readonly AnyV3Table[]` is still rejected, - // here and on the pre-A-4 signature alike. That is the honest answer rather - // than a gap: such an `S` admits `readonly []`, so the wrapper cannot - // promise what `Encryption` requires. Constrain it to a non-empty tuple, as - // above, and it compiles. - async function makeLooseClient( - schemas: S, - ) { - // @ts-expect-error - a loose `S` cannot prove it is non-empty - return await Encryption({ schemas }) - } - void makeLooseClient - }) - - it('accepts arrays whose type cannot prove non-emptiness', () => { - const shared: AnyV3Table[] = [] - expectTypeOf(Encryption).toBeCallableWith({ schemas: shared }) - - const frozen: ReadonlyArray = [] - expectTypeOf(Encryption).toBeCallableWith({ schemas: frozen }) - - expectTypeOf(Encryption).toBeCallableWith({ schemas: [...shared] }) - expectTypeOf(Encryption).toBeCallableWith({ - schemas: shared.filter(() => false), - }) - }) - - // A-4: closing S-6 with a non-empty TUPLE constraint rejected every schema - // array that is not a literal, which is most real code — a shared module - // export, anything built from introspection, anything `readonly`. The - // non-emptiness check moved to the `schemas` property so these compile again - // while `[]` above still does not. One case per form that was broken. - it('accepts schema arrays that are not literals', async () => { - const shared: AnyV3Table[] = [users] - expectTypeOf(await Encryption({ schemas: shared })).toEqualTypeOf< - TypedEncryptionClient - >() - - // `ReadonlyArray` is the form `@cipherstash/prisma-next` exposes publicly. - const frozen: ReadonlyArray = [users] - expectTypeOf(await Encryption({ schemas: frozen })).toEqualTypeOf< - TypedEncryptionClient - >() - - const built: AnyV3Table[] = [] - built.push(users) - expectTypeOf(await Encryption({ schemas: built })).toEqualTypeOf< - TypedEncryptionClient - >() - - expectTypeOf(await Encryption({ schemas: [...shared] })).toEqualTypeOf< - TypedEncryptionClient - >() - }) - - // The widening must not cost the literal path its precision — that typing is - // the entire reason the typed client exists. `const` inference has to survive. - it('keeps per-column typing on the literal path', async () => { - const client = await Encryption({ schemas: [users] }) - - expectTypeOf(client.encrypt).toBeCallableWith('a@b.com', { - table: users, - column: users.email, - }) - // @ts-expect-error - `email` is a text domain, not a number - client.encrypt(123, { table: users, column: users.email }) - // @ts-expect-error - `createdAt` is a timestamp domain, not a string - client.encrypt('2020-01-01', { table: users, column: users.createdAt }) - }) - - // S-4: `eqlVersion: 2` selects the NOMINAL overload, because the typed client - // cannot author v3 columns in v2 mode. The types used to claim the typed - // client, so `decryptModel(row, table, lockContext)` compiled and then - // silently dropped `table` and `lockContext`. - // - // Over an all-v3 schema set this combination is now REJECTED AT RUNTIME - // (#772 review, finding 8) — v2 wire into `eql_v3_*` columns is a - // contradiction, and `EncryptionV3` used to force `eqlVersion: 3` precisely - // to stop it. This assertion therefore records overload resolution only; the - // call itself throws. `init-strategy.test.ts` pins the throw. - it('forcing eqlVersion 2 selects the nominal overload (and is refused at runtime for v3 schemas)', async () => { - const client = await Encryption({ - schemas: [users], - config: { eqlVersion: 2 }, - }) - expectTypeOf(client).toEqualTypeOf() - // The typed-only three-arg decrypt is therefore not available — which is - // exactly what the runtime does. - // @ts-expect-error - the nominal client takes the model alone - client.decryptModel({ email: 'x' }, users) - }) - - it('an explicit eqlVersion 3 keeps the typed client', async () => { - const client = await Encryption({ - schemas: [users], - config: { eqlVersion: 3 }, - }) - expectTypeOf(client).toEqualTypeOf< - TypedEncryptionClient - >() - }) -}) - -describe('naming the client type', () => { - it('EncryptionClientFor names the typed client for a v3 tuple', async () => { - const client: EncryptionClientFor = - await Encryption({ schemas: [users] }) - expectTypeOf(client).toEqualTypeOf< - TypedEncryptionClient - >() - }) - - // A-6: the form generic code needs — an integration adapter that builds its - // table per test family cannot name a tuple. This must track `Encryption`'s - // own constraint: while `EncryptionClientFor` still required a non-empty - // TUPLE it fell through to the nominal client here, silently handing the - // wrong type to exactly the callers the widening above exists to serve. - it('EncryptionClientFor names the typed client for a loose v3 array', () => { - expectTypeOf>().toEqualTypeOf< - TypedEncryptionClient - >() - }) - - it('EncryptionClientFor falls back to the nominal client', () => { - expectTypeOf< - EncryptionClientFor - >().toEqualTypeOf() - - // `never extends X` is true, so an empty tuple satisfies "every element is - // a v3 table" — the emptiness arm has to be checked inside the v3 branch. - expectTypeOf< - EncryptionClientFor - >().toEqualTypeOf() - }) - - // S-2: `ReturnType` reads the LAST overload, so this idiom resolves to the - // nominal client no matter what schemas you pass. Overload order cannot - // satisfy both forms — putting the nominal signature first mis-resolves v3 - // schemas instead, because a v3 table structurally satisfies `BuildableTable`. - // `EncryptionClientFor` above is the supported idiom; this pins the trap so it - // cannot start silently resolving differently. - it('ReturnType resolves to the NOMINAL client', () => { - expectTypeOf< - Awaited> - >().toEqualTypeOf() - }) -}) - -describe('reading legacy EQL v2 models through a typed client', () => { - // The compatibility promise: a v3-configured client must read rows written - // before the upgrade. Their table is not — and cannot be — a member of the - // client's v3 schema tuple, so the table-less call has to type-check. - it('accepts a table-less decryptModel', async () => { - const client = await Encryption({ schemas: [users] }) - expectTypeOf(client.decryptModel).toBeCallableWith({ - pk: 'a', - email: { k: 'ct', v: 2, c: 'ciphertext', i: { t: 'legacy', c: 'email' } }, - }) - expectTypeOf(client.bulkDecryptModels).toBeCallableWith([ - { pk: 'a', email: { k: 'ct', v: 2, c: 'x', i: { t: 'l', c: 'email' } } }, - ]) - }) - - it('still rejects a table this client was not built with', async () => { - const client = await Encryption({ schemas: [users] }) - const unregistered = encryptedTable('other', { note: types.TextEq('note') }) - // @ts-expect-error - `other` is not a member of the client's schema tuple - client.decryptModel({ note: 'x' }, unregistered) - }) -}) diff --git a/packages/stack/__tests__/encryption-v3-only.test-d.ts b/packages/stack/__tests__/encryption-v3-only.test-d.ts new file mode 100644 index 000000000..c7d610e73 --- /dev/null +++ b/packages/stack/__tests__/encryption-v3-only.test-d.ts @@ -0,0 +1,90 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { Encryption, type EncryptionClient } from '@/encryption' +import { type AnyV3Table, encryptedTable, types } from '@/eql/v3' +import type { ClientConfig, EncryptionClientConfig } from '@/types' + +const users = encryptedTable('users', { + email: types.TextEq('email'), + createdAt: types.TimestampOrd('created_at'), +}) + +describe('v3-only Encryption contract', () => { + it('returns the schema-derived client for a literal tuple', async () => { + const client = await Encryption({ schemas: [users] }) + expectTypeOf(client).toEqualTypeOf< + EncryptionClient + >() + }) + + it('accepts generic tuples and widened arrays', async () => { + async function make( + schemas: S, + ) { + return Encryption({ schemas }) + } + + expectTypeOf(await make([users])).toEqualTypeOf< + EncryptionClient + >() + + const widened: AnyV3Table[] = [users] + expectTypeOf(await Encryption({ schemas: widened })).toEqualTypeOf< + EncryptionClient + >() + }) + + it('rejects empty literals and removed version configuration', () => { + // @ts-expect-error - at least one table is required + Encryption({ schemas: [] }) + const config: ClientConfig = { + // @ts-expect-error - Stack always authors EQL v3 + eqlVersion: 2, + } + void config + }) + + it('uses EncryptionClient as the named client type', () => { + expectTypeOf>>().toEqualTypeOf< + EncryptionClient + >() + }) +}) + +/** + * The exported config type must not launder an empty schema set — the same guard + * `wasm-inline-schemas.test-d.ts` holds for `WasmEncryptionConfig`. + * + * `@ts-expect-error` on the inline `Encryption({ schemas: [] })` above is not + * enough on its own: it only covers a FRESH literal. A config built once and + * passed around — the shape this type exists to serve — went through a default + * type argument of `readonly AnyV3Table[]`, where `S['length']` widens to + * `number`, `number extends 0` is false, and the conditional collapses back to + * the widened array. It typechecked clean and threw at `Encryption()`. + */ +describe('EncryptionClientConfig', () => { + it('rejects an empty schema set on the exported config type', () => { + // @ts-expect-error - at least one table is required + const cfg: EncryptionClientConfig = { schemas: [] } + void cfg + }) + + it('accepts a populated config and passes it to the factory', async () => { + const cfg: EncryptionClientConfig = { schemas: [users] } + expectTypeOf(await Encryption(cfg)).toEqualTypeOf< + EncryptionClient + >() + }) + + it('still parameterizes over an explicit schema tuple', () => { + expectTypeOf< + EncryptionClientConfig['schemas'] + >().toEqualTypeOf() + }) + + it('still accepts a widened array passed inline to the factory', async () => { + const widened: AnyV3Table[] = [users] + expectTypeOf(await Encryption({ schemas: widened })).toEqualTypeOf< + EncryptionClient + >() + }) +}) diff --git a/packages/stack/__tests__/error-codes.test.ts b/packages/stack/__tests__/error-codes.test.ts index 9c4a268b2..09c3cf011 100644 --- a/packages/stack/__tests__/error-codes.test.ts +++ b/packages/stack/__tests__/error-codes.test.ts @@ -2,9 +2,9 @@ import 'dotenv/config' import { ProtectError as FfiProtectError } from '@cipherstash/protect-ffi' import { beforeAll, describe, expect, it } from 'vitest' import type { EncryptionClient } from '@/encryption' +import { encryptedTable, types } from '@/eql/v3' import { EncryptionErrorTypes } from '@/errors' import { Encryption } from '@/index' -import { encryptedColumn, encryptedTable } from '@/schema' import type { BulkDecryptPayload } from '@/types' /** FFI tests require longer timeout due to client initialization */ @@ -20,19 +20,19 @@ describe('FFI Error Code Preservation', () => { // Schema with a valid column for testing const testSchema = encryptedTable('test_table', { - email: encryptedColumn('email').equality(), - bio: encryptedColumn('bio').freeTextSearch(), - age: encryptedColumn('age').dataType('number').orderAndRange(), + email: types.TextEq('email'), + bio: types.TextMatch('bio'), + age: types.IntegerOrd('age'), }) // Schema without indexes for testing non-FFI validation const noIndexSchema = encryptedTable('no_index_table', { - raw: encryptedColumn('raw'), + raw: types.Text('raw'), }) // Schema with non-existent column for triggering FFI UNKNOWN_COLUMN error const badModelSchema = encryptedTable('test_table', { - nonexistent: encryptedColumn('nonexistent_column'), + nonexistent: types.Text('nonexistent_column'), }) beforeAll(async () => { @@ -55,7 +55,7 @@ describe('FFI Error Code Preservation', () => { 'returns UNKNOWN_COLUMN code for non-existent column', async () => { // Create a fake column that doesn't exist in the schema - const fakeColumn = encryptedColumn('nonexistent_column').equality() + const fakeColumn = types.TextEq('nonexistent_column') const result = await protectClient.encryptQuery('test', { column: fakeColumn, @@ -111,7 +111,7 @@ describe('FFI Error Code Preservation', () => { it( 'preserves error code in batch operations', async () => { - const fakeColumn = encryptedColumn('nonexistent_column').equality() + const fakeColumn = types.TextEq('nonexistent_column') const result = await protectClient.encryptQuery([ { @@ -152,7 +152,7 @@ describe('FFI Error Code Preservation', () => { it( 'returns UNKNOWN_COLUMN code for non-existent column in encrypt', async () => { - const fakeColumn = encryptedColumn('nonexistent_column') + const fakeColumn = types.Text('nonexistent_column') const result = await protectClient.encrypt('test', { column: fakeColumn, @@ -209,7 +209,7 @@ describe('FFI Error Code Preservation', () => { it( 'returns UNKNOWN_COLUMN code for non-existent column', async () => { - const fakeColumn = encryptedColumn('nonexistent_column') + const fakeColumn = types.Text('nonexistent_column') const result = await protectClient.bulkEncrypt( [{ plaintext: 'test1' }, { plaintext: 'test2' }], diff --git a/packages/stack/__tests__/fixtures-query-contract.test.ts b/packages/stack/__tests__/fixtures-query-contract.test.ts new file mode 100644 index 000000000..b2fae3673 --- /dev/null +++ b/packages/stack/__tests__/fixtures-query-contract.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { + inferIndexType, + resolveIndexType, +} from '@/encryption/helpers/infer-index-type' +import { articles, metadata, products, users } from './fixtures' + +/** + * The credentialed `encrypt-query` suite asserts live EQL term keys (`hm`, `bf`, + * `op`, `ob`) and preflight error messages that are decided entirely by which + * index `inferIndexType`/`resolveIndexType` picks for each shared fixture + * column. Nothing else pins that mapping down, so swapping a fixture's v3 domain + * silently re-points those tests at a different index — they then pass for the + * wrong reason, or fail with a message that reads like an unrelated product + * regression. + * + * #829 shipped exactly that: `articles.content` moved from a match-only column + * to `types.TextSearch`, whose `unique + ope + match` derivation outranks `match` + * in `inferIndexType`'s priority order. The numeric-value guard in + * `validateValueIndexCompatibility` (which only fires for `match`) stopped + * firing, and "fails when encrypting number with auto-inferred match index" + * surfaced a protect-ffi cast error instead of the guard's message. + * + * These assertions are credential-free, so a fixture that drifts fails on the + * PR that drifts it rather than on the first CI run holding live credentials. + */ +describe('shared query fixtures resolve to the indexes the live suite assumes', () => { + describe('auto-inference (no explicit queryType)', () => { + it.each([ + { name: 'users.email', column: () => users.email, expected: 'unique' }, + { name: 'users.age', column: () => users.age, expected: 'ope' }, + { + name: 'articles.content', + column: () => articles.content, + expected: 'match', + }, + { name: 'products.price', column: () => products.price, expected: 'ore' }, + ])('$name infers $expected', ({ column, expected }) => { + expect(inferIndexType(column())).toBe(expected) + }) + + it('metadata.raw carries no queryable index at all', () => { + expect(() => inferIndexType(metadata.raw)).toThrow( + /no indexes configured/, + ) + }) + }) + + describe('explicit queryType', () => { + it('users.bio answers freeTextSearch through the match index', () => { + expect(resolveIndexType(users.bio, 'freeTextSearch').indexType).toBe( + 'match', + ) + }) + + it('users.age answers equality through its ordering index, not hm', () => { + // A numeric `_ord` domain carries no `unique`/`hm`; equality resolves to + // the same OPE term `orderAndRange` emits, distinguished by the SQL `=`. + expect(resolveIndexType(users.age, 'equality').indexType).toBe('ope') + }) + + it('articles.content cannot answer equality', () => { + // Keeps a genuinely equality-incapable fixture available for the live + // suite's queryType-mismatch assertion. + expect(() => resolveIndexType(articles.content, 'equality')).toThrow( + /Index type "unique" is not configured/, + ) + }) + }) +}) diff --git a/packages/stack/__tests__/fixtures/index.ts b/packages/stack/__tests__/fixtures/index.ts index 16e19bc74..dff2ebd19 100644 --- a/packages/stack/__tests__/fixtures/index.ts +++ b/packages/stack/__tests__/fixtures/index.ts @@ -1,5 +1,5 @@ import { expect } from 'vitest' -import { encryptedColumn, encryptedTable } from '@/schema' +import { encryptedTable, types } from '@/eql/v3' // ============ Schema Fixtures ============ @@ -7,48 +7,59 @@ import { encryptedColumn, encryptedTable } from '@/schema' * Users table with multiple index types for testing */ export const users = encryptedTable('users', { - email: encryptedColumn('email').equality(), - bio: encryptedColumn('bio').freeTextSearch(), - age: encryptedColumn('age').dataType('number').orderAndRange(), + email: types.TextEq('email'), + bio: types.TextSearch('bio'), + age: types.IntegerOrd('age'), }) /** - * Articles table with only freeTextSearch (for auto-inference test) + * Articles table with only freeTextSearch (for auto-inference test). + * + * MUST stay a match-only domain (`types.TextMatch`, not `types.TextSearch`): + * `TextSearch` derives `unique + ope + match`, and `unique` outranks `match` in + * `inferIndexType`'s priority order — which silently re-points every + * auto-inference test here at the `hm` term and disables the match-only numeric + * guard. Pinned by `fixtures-query-contract.test.ts`. */ export const articles = encryptedTable('articles', { - content: encryptedColumn('content').freeTextSearch(), + content: types.TextMatch('content'), }) /** - * Products table with only orderAndRange (for auto-inference test) + * Products table with only orderAndRange (for auto-inference test). + * + * Deliberately the block-ORE ordering flavour (`ore` index, `ob` term), where + * `users.age` is the CLLW-OPE one (`ope` index, `op` term), so the live suite + * covers both v3 ordering flavours. Pinned by + * `fixtures-query-contract.test.ts`. */ export const products = encryptedTable('products', { - price: encryptedColumn('price').dataType('number').orderAndRange(), + price: types.NumericOrdOre('price'), }) /** * Metadata table with no indexes (for validation error test) */ export const metadata = encryptedTable('metadata', { - raw: encryptedColumn('raw'), + raw: types.Text('raw'), }) /** * Documents table with searchable JSON column (for STE Vec queries) */ export const jsonbSchema = encryptedTable('documents', { - id: encryptedColumn('id'), - metadata: encryptedColumn('metadata').searchableJson(), + id: types.Text('id'), + metadata: types.Json('metadata'), }) /** * Schema fixture with mixed column types including JSON. */ export const mixedSchema = encryptedTable('records', { - id: encryptedColumn('id'), - email: encryptedColumn('email').equality(), - name: encryptedColumn('name').freeTextSearch(), - metadata: encryptedColumn('metadata').searchableJson(), + id: types.Text('id'), + email: types.TextEq('email'), + name: types.TextSearch('name'), + metadata: types.Json('metadata'), }) // ============ Mock Factories ============ @@ -68,6 +79,30 @@ export function createMockLockContext(overrides?: { // ============ Test Helpers ============ +/** + * Whether live CipherStash credentials are present in the environment. + */ +export const hasLiveCredentials = Boolean( + process.env.CS_WORKSPACE_CRN && + process.env.CS_CLIENT_ID && + process.env.CS_CLIENT_KEY && + process.env.CS_CLIENT_ACCESS_KEY, +) + +/** + * Gate for suites that call the real CipherStash service. + * + * Locally, skip: without credentials `Encryption()` throws in `beforeAll`, and + * vitest reports that as "Failed Suites 1 / Tests N skipped" — indistinguishable + * from ordinary credential gating. That ambiguity is how #829's 21 stale EQL v2 + * assertions got attributed to missing credentials and reached CI. + * + * In CI, do NOT skip: missing credentials must fail the build. A plain + * `skipIf(!hasLiveCredentials)` would be worse than the status quo — credential + * rot would leave these suites covering nothing while CI stayed green. + */ +export const skipWithoutLiveCredentials = !hasLiveCredentials && !process.env.CI + /** * Unwraps a Result type, throwing an error if it's a failure. * Use this to simplify test assertions when you expect success. diff --git a/packages/stack/__tests__/infer-index-type.test.ts b/packages/stack/__tests__/infer-index-type.test.ts index 92c731f42..c738f8982 100644 --- a/packages/stack/__tests__/infer-index-type.test.ts +++ b/packages/stack/__tests__/infer-index-type.test.ts @@ -4,7 +4,7 @@ import { inferQueryOpFromPlaintext, validateIndexType, } from '@/encryption/helpers/infer-index-type' -import { encryptedColumn, encryptedTable } from '@/schema' +import { encryptedColumn, encryptedTable } from '@/schema/internal' describe('infer-index-type helpers', () => { const users = encryptedTable('users', { diff --git a/packages/stack/__tests__/init-strategy.test.ts b/packages/stack/__tests__/init-strategy.test.ts index b9357193d..d8a3459aa 100644 --- a/packages/stack/__tests__/init-strategy.test.ts +++ b/packages/stack/__tests__/init-strategy.test.ts @@ -15,10 +15,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { __resetStrategyDeprecationWarningForTests } from '@/encryption' -import { EncryptionV3 } from '@/encryption/v3' +import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' -import { encryptedColumn, encryptedTable } from '@/schema' -import type { AuthStrategy, BuildableTable } from '@/types' +import type { AuthStrategy } from '@/types' vi.mock('@cipherstash/protect-ffi', () => ({ newClient: vi.fn(async () => ({ __mock: 'client' })), @@ -27,7 +26,7 @@ vi.mock('@cipherstash/protect-ffi', () => ({ import * as ffi from '@cipherstash/protect-ffi' const users = encryptedTable('users', { - email: encryptedColumn('email'), + email: types.Text('email'), }) // Silence + capture the deprecation warning, and reset its once-per-process @@ -181,108 +180,63 @@ describe('Encryption config.strategy (deprecated alias)', () => { }) }) -// A minimal structural EQL v3 table: what marks a table as v3 for wire-format -// detection is its `buildColumnKeyMap()` method (v2 tables have none). Built -// by hand rather than via `@cipherstash/stack/v3` so this suite pins the -// structural contract `Encryption` actually dispatches on. -const v3Table = (tableName = 'v3_users'): BuildableTable => - ({ - tableName, - build: () => ({ - tableName, - columns: { email: { cast_as: 'text', indexes: { unique: {} } } }, - }), - buildColumnKeyMap: () => ({ email: 'email' }), - }) as BuildableTable - -describe('Encryption config.eqlVersion', () => { +describe('Encryption v3 wire format', () => { // biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args const lastNewClientOpts = () => vi.mocked(ffi.newClient).mock.calls.at(-1)![0] as any - it('leaves eqlVersion undefined for a v2 schema set (FFI default, byte-identical v2 wire)', async () => { + it('always constructs the FFI client with eqlVersion 3', async () => { await Encryption({ schemas: [users] }) - expect(lastNewClientOpts().eqlVersion).toBeUndefined() - }) - - it('rejects legacy v2 searchableJson before constructing an incompatible FFI client', async () => { - const legacyJson = encryptedTable('documents', { - metadata: encryptedColumn('metadata').searchableJson(), - }) - await expect(Encryption({ schemas: [legacyJson] })).rejects.toThrow( - /legacy EQL v2 schema is not supported.*types\.Json\(\)/, - ) - expect(ffi.newClient).not.toHaveBeenCalled() - }) - - it('auto-detects eqlVersion 3 when every schema is a v3 table', async () => { - await Encryption({ schemas: [v3Table()] }) - expect(lastNewClientOpts().eqlVersion).toBe(3) }) - it('forwards an explicit eqlVersion for a v2 schema set', async () => { - await Encryption({ schemas: [users], config: { eqlVersion: 3 } }) - - expect(lastNewClientOpts().eqlVersion).toBe(3) - }) - - // #772 review, finding 8. This used to be honoured, and the resulting client - // wrote `eql_v2_encrypted` payloads into `eql_v3_*` columns with no - // diagnostic at any layer — the type surface types it as the nominal client, - // which matches what the runtime returns, so nothing disagreed loudly enough - // to notice. - it('rejects an explicit eqlVersion 2 over a v3 schema set before constructing the FFI client', async () => { + it('rejects a non-v3 schema before constructing the FFI client', async () => { + const legacyTable = { + tableName: 'legacy_users', + build: () => ({ tableName: 'legacy_users', columns: {} }), + } await expect( - Encryption({ schemas: [v3Table()], config: { eqlVersion: 2 } }), - ).rejects.toThrow(/entirely EQL v3/) - + Encryption({ schemas: [legacyTable as never] }), + ).rejects.toThrow(/is not an EQL v3 table/) expect(ffi.newClient).not.toHaveBeenCalled() }) - // The escape hatch survives where it is actually used: minting v2 wire from - // a v2 schema set, which is how the v2-read-compat fixtures are produced. - it('still honours an explicit eqlVersion 2 for a v2 schema set', async () => { - await Encryption({ schemas: [users], config: { eqlVersion: 2 } }) - - expect(lastNewClientOpts().eqlVersion).toBe(2) - }) + it('rejects the removed config.eqlVersion escape hatch at runtime', async () => { + await expect( + Encryption({ + schemas: [users], + config: { eqlVersion: 2 } as never, + }), + ).rejects.toThrow(/config\.eqlVersion.*removed/) - it('throws on a mixed v2 + v3 schema set — one client emits one wire format', async () => { - await expect(Encryption({ schemas: [users, v3Table()] })).rejects.toThrow( - /cannot mix EQL v2 and EQL v3 tables/, - ) + expect(ffi.newClient).not.toHaveBeenCalled() }) - it('throws on a mixed schema set even with an explicit eqlVersion', async () => { + it('rejects an explicit eqlVersion 3, which is a stale config and not a request', async () => { await expect( - Encryption({ schemas: [users, v3Table()], config: { eqlVersion: 3 } }), - ).rejects.toThrow(/cannot mix EQL v2 and EQL v3 tables/) - }) - - it('EncryptionV3 creates the underlying client with eqlVersion 3', async () => { - // The duck-typed table satisfies the runtime contract; the type-level - // AnyV3Table constraint is beside the point for this wiring assertion. - await EncryptionV3({ schemas: [v3Table() as never] }) + Encryption({ + schemas: [users], + config: { eqlVersion: 3 } as never, + }), + ).rejects.toThrow(/config\.eqlVersion.*removed/) - expect(lastNewClientOpts().eqlVersion).toBe(3) + expect(ffi.newClient).not.toHaveBeenCalled() }) - it('EncryptionV3 rejects an explicit eqlVersion 2 identically', async () => { - // Post-collapse `EncryptionV3` IS `Encryption` (a deprecated alias), so it - // no longer independently pins the wire format. The invariant it used to - // enforce by forcing `eqlVersion: 3` now lives in `resolveEqlVersion`, so - // the outcome for a caller upgrading from the old `EncryptionV3` is the - // same in the case that matters: the contradiction is refused rather than - // silently building a v2-wire client over v3 concrete domains. + /** + * `eqlVersion?: never` on `ClientConfig` has declared type `undefined` without + * `exactOptionalPropertyTypes` — which this repo does not enable — so the type + * accepts `eqlVersion: undefined` and cannot be made to reject it. A bare + * `Object.hasOwn` check therefore threw on a config the emitted declarations + * had already accepted. An explicit `undefined` names no version and carries + * no migration hazard, so the runtime tolerates exactly that one value. + */ + it('tolerates an explicitly undefined eqlVersion, which the type permits', async () => { await expect( - EncryptionV3({ - schemas: [v3Table() as never], - config: { eqlVersion: 2 }, - }), - ).rejects.toThrow(/entirely EQL v3/) + Encryption({ schemas: [users], config: { eqlVersion: undefined } }), + ).resolves.toBeDefined() - expect(ffi.newClient).not.toHaveBeenCalled() + expect(lastNewClientOpts().eqlVersion).toBe(3) }) }) diff --git a/packages/stack/__tests__/json-protect.test.ts b/packages/stack/__tests__/json-protect.test.ts deleted file mode 100644 index 34e3eb2b1..000000000 --- a/packages/stack/__tests__/json-protect.test.ts +++ /dev/null @@ -1,1150 +0,0 @@ -import 'dotenv/config' -import { beforeAll, describe, expect, it } from 'vitest' -import type { EncryptionClient } from '@/encryption' -import { LockContext } from '@/identity' -import { Encryption } from '@/index' -import { encryptedColumn, encryptedField, encryptedTable } from '@/schema' - -const users = encryptedTable('users', { - email: encryptedColumn('email').freeTextSearch().equality().orderAndRange(), - address: encryptedColumn('address').freeTextSearch(), - json: encryptedColumn('json').dataType('json'), - metadata: { - profile: encryptedField('metadata.profile').dataType('json'), - settings: { - preferences: encryptedField('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: EncryptionClient - -beforeAll(async () => { - protectClient = await Encryption({ - 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 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 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 Encryption({ 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 Encryption({ 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 Encryption({ 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/stack/__tests__/keysets.test.ts b/packages/stack/__tests__/keysets.test.ts index 708eae562..b886f428a 100644 --- a/packages/stack/__tests__/keysets.test.ts +++ b/packages/stack/__tests__/keysets.test.ts @@ -1,11 +1,11 @@ import 'dotenv/config' import { ensureKeyset } from '@cipherstash/protect-ffi' import { beforeAll, describe, expect, it } from 'vitest' +import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' -import { encryptedColumn, encryptedTable } from '@/schema' const users = encryptedTable('users', { - email: encryptedColumn('email'), + email: types.Text('email'), }) let testKeysetId: string diff --git a/packages/stack/__tests__/lock-context-wiring.test.ts b/packages/stack/__tests__/lock-context-wiring.test.ts index 9a578c014..305617aab 100644 --- a/packages/stack/__tests__/lock-context-wiring.test.ts +++ b/packages/stack/__tests__/lock-context-wiring.test.ts @@ -15,9 +15,9 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { encryptedTable, types } from '@/eql/v3' import { LockContext } from '@/identity' import { Encryption } from '@/index' -import { encryptedColumn, encryptedTable } from '@/schema' // A protect-ffi-shaped encrypted payload (passes the SDK's // `isEncryptedPayload` check so model decrypt detects encrypted fields). @@ -47,7 +47,7 @@ import * as ffi from '@cipherstash/protect-ffi' import type { EncryptionClient } from '@/encryption' const users = encryptedTable('users', { - email: encryptedColumn('email').equality(), + email: types.TextEq('email'), }) const IDENTITY_CLAIM = { identityClaim: ['sub'] } diff --git a/packages/stack/__tests__/lock-context.test.ts b/packages/stack/__tests__/lock-context.test.ts index 922d2c67c..70e415f40 100644 --- a/packages/stack/__tests__/lock-context.test.ts +++ b/packages/stack/__tests__/lock-context.test.ts @@ -1,8 +1,8 @@ import 'dotenv/config' import { OidcFederationStrategy } from '@cipherstash/auth' import { describe, expect, it } from 'vitest' +import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' -import { encryptedColumn, encryptedTable } from '@/schema' /** * Live, identity-bound encryption round-trips (gated on `USER_JWT`). @@ -18,8 +18,8 @@ import { encryptedColumn, encryptedTable } from '@/schema' * `CS_CLIENT_KEY`; skips silently if `USER_JWT` is absent. */ const users = encryptedTable('users', { - email: encryptedColumn('email').freeTextSearch().equality().orderAndRange(), - address: encryptedColumn('address').freeTextSearch(), + email: types.TextSearch('email'), + address: types.TextMatch('address'), }) const IDENTITY_CLAIM = { identityClaim: ['sub'] } diff --git a/packages/stack/__tests__/match-needle-guard.test.ts b/packages/stack/__tests__/match-needle-guard.test.ts index d5ca8048c..664cac7e6 100644 --- a/packages/stack/__tests__/match-needle-guard.test.ts +++ b/packages/stack/__tests__/match-needle-guard.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { MatchIndexOpts } from '@/schema' +import type { MatchIndexOpts } from '@/schema/internal' import { matchNeedleError, matchNeedleMinLength } from '@/schema/match-defaults' // The floor a free-text needle must clear before it yields any ngram. A needle diff --git a/packages/stack/__tests__/model-column-mapping.test.ts b/packages/stack/__tests__/model-column-mapping.test.ts index 55c19002b..26c7fd490 100644 --- a/packages/stack/__tests__/model-column-mapping.test.ts +++ b/packages/stack/__tests__/model-column-mapping.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import { resolveEncryptColumnMap } from '@/encryption/helpers/model-helpers' import { encryptedTable, types } from '@/eql/v3' -import { encryptedColumn, encryptedTable as encryptedTableV2 } from '@/schema' +import { + encryptedColumn, + encryptedTable as encryptedTableV2, +} from '@/schema/internal' // `resolveEncryptColumnMap` is how the model path reconciles the two keyings a // table can use: models are matched by JS property name, but the FFI / encrypt diff --git a/packages/stack/__tests__/nested-models.test.ts b/packages/stack/__tests__/nested-models.test.ts deleted file mode 100644 index 05519d01b..000000000 --- a/packages/stack/__tests__/nested-models.test.ts +++ /dev/null @@ -1,959 +0,0 @@ -import 'dotenv/config' -import { describe, expect, it, vi } from 'vitest' -import { LockContext } from '@/identity' -import { Encryption } from '@/index' -import { encryptedColumn, encryptedField, encryptedTable } from '@/schema' - -const users = encryptedTable('users', { - email: encryptedColumn('email').freeTextSearch().equality().orderAndRange(), - address: encryptedColumn('address').freeTextSearch(), - name: encryptedColumn('name').freeTextSearch(), - example: { - field: encryptedField('example.field'), - nested: { - deeper: encryptedField('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 Encryption({ 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 Encryption({ 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 Encryption({ 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 Encryption({ 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 Encryption({ 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 Encryption({ 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 Encryption({ 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 Encryption({ 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 Encryption({ 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 Encryption({ 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 Encryption({ 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 Encryption({ 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 Encryption({ 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 Encryption({ 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 Encryption({ 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 Encryption({ 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 Encryption({ 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 Encryption({ 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/stack/__tests__/null-guards.test.ts b/packages/stack/__tests__/null-guards.test.ts index dcd5deaa8..2479abc5f 100644 --- a/packages/stack/__tests__/null-guards.test.ts +++ b/packages/stack/__tests__/null-guards.test.ts @@ -12,10 +12,10 @@ import { BulkEncryptOperation } from '@/encryption/operations/bulk-encrypt' import { DecryptOperation } from '@/encryption/operations/decrypt' import { EncryptOperation } from '@/encryption/operations/encrypt' import { EncryptQueryOperation } from '@/encryption/operations/encrypt-query' -import { encryptedColumn, encryptedTable } from '@/schema' +import { encryptedTable, types } from '@/eql/v3' const table = encryptedTable('null-guards-test', { - metadata: encryptedColumn('metadata').searchableJson(), + metadata: types.Json('metadata'), }) // Any truthy stand-in — the guard returns before the client is touched. diff --git a/packages/stack/__tests__/number-protect.test.ts b/packages/stack/__tests__/number-protect.test.ts deleted file mode 100644 index 8bc084982..000000000 --- a/packages/stack/__tests__/number-protect.test.ts +++ /dev/null @@ -1,760 +0,0 @@ -import 'dotenv/config' -import { beforeAll, describe, expect, it, test } from 'vitest' -import type { EncryptionClient } from '@/encryption' -import { LockContext } from '@/identity' -import { Encryption } from '@/index' -import { encryptedColumn, encryptedField, encryptedTable } from '@/schema' - -const users = encryptedTable('users', { - email: encryptedColumn('email').freeTextSearch().equality().orderAndRange(), - address: encryptedColumn('address').freeTextSearch(), - age: encryptedColumn('age').dataType('number').equality().orderAndRange(), - score: encryptedColumn('score').dataType('number').equality().orderAndRange(), - metadata: { - count: encryptedField('metadata.count').dataType('number'), - level: encryptedField('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: EncryptionClient - -beforeAll(async () => { - protectClient = await Encryption({ - 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) - - // 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 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 Encryption({ 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 Encryption({ 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 Encryption({ schemas: [users] }) - - const decryptedModel = { - id: '3', - email: 'test3@example.com', - metadata: { - count: undefined, - level: undefined, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify undefined fields are preserved - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.metadata?.count).toBeUndefined() - expect(encryptedModel.data.metadata?.level).toBeUndefined() - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) -}) - -describe('encryptQuery for numbers', () => { - it('should create encrypted query for number fields', async () => { - const result = await protectClient.encryptQuery([ - { value: 25, column: users.age, table: users, queryType: 'equality' }, - { value: 100, column: users.score, table: users, queryType: 'equality' }, - ]) - - if (result.failure) { - throw new Error(`[protect]: ${result.failure.message}`) - } - - expect(result.data).toHaveLength(2) - expect(result.data[0]).toHaveProperty('v', 2) - expect(result.data[1]).toHaveProperty('v', 2) - }, 30000) -}) - -describe('Performance tests', () => { - it('should handle large numbers of numbers efficiently', async () => { - const largeNumArray = Array.from({ length: 100 }, (_, i) => ({ - id: i, - data: { - age: i + 18, // Ages 18-117 - score: (i % 100) + 1, // Scores 1-100 - }, - })) - - const numPayloads = largeNumArray.map((item, index) => ({ - id: `user${index}`, - plaintext: item.data.age, - })) - - const encryptedData = await protectClient.bulkEncrypt(numPayloads, { - column: users.age, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(100) - - // Decrypt the data - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify all data is preserved - expect(decryptedData.data).toHaveLength(100) - - for (let i = 0; i < 100; i++) { - expect(decryptedData.data[i].id).toBe(`user${i}`) - expect(decryptedData.data[i].data).toEqual(largeNumArray[i].data.age) - } - }, 60000) -}) - -describe('Advanced scenarios', () => { - it('should handle boundary values', async () => { - const boundaryValues = [ - Number.MIN_SAFE_INTEGER, - -2147483648, // Min 32-bit signed integer - -1, - 0, - 1, - 2147483647, // Max 32-bit signed integer - Number.MAX_SAFE_INTEGER, - ] - - for (const value of boundaryValues) { - const ciphertext = await protectClient.encrypt(value, { - column: users.age, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: value, - }) - } - }, 30000) -}) - -const invalidPlaintexts = [ - '400', - 'aaa', - '100a', - '73.51', - {}, - [], - [123], - { num: 123 }, -] - -describe('Invalid or uncoercable values', () => { - test.each(invalidPlaintexts)('should fail to encrypt', async (input) => { - const result = await protectClient.encrypt(input, { - column: users.age, - table: users, - }) - - expect(result.failure).toBeDefined() - expect(result.failure?.message).toContain('Cannot convert') - }, 30000) -}) diff --git a/packages/stack/__tests__/protect-ops.test.ts b/packages/stack/__tests__/protect-ops.test.ts index c92f4595e..da1c13c7c 100644 --- a/packages/stack/__tests__/protect-ops.test.ts +++ b/packages/stack/__tests__/protect-ops.test.ts @@ -1,13 +1,13 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' import type { EncryptionClient } from '@/encryption' +import { encryptedTable, types } from '@/eql/v3' import { LockContext } from '@/identity' import { Encryption } from '@/index' -import { encryptedColumn, encryptedTable } from '@/schema' const users = encryptedTable('users', { - email: encryptedColumn('email').freeTextSearch().equality().orderAndRange(), - address: encryptedColumn('address').freeTextSearch(), + email: types.TextSearch('email'), + address: types.TextMatch('address'), }) type User = { diff --git a/packages/stack/__tests__/resolve-eql-version.test.ts b/packages/stack/__tests__/resolve-eql-version.test.ts deleted file mode 100644 index 2ec1d0c5c..000000000 --- a/packages/stack/__tests__/resolve-eql-version.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * The wire-format detection matrix behind `Encryption({ schemas })`. - * - * `resolveEqlVersion` decides, from the schema set alone, which EQL wire format - * the FFI client will emit. It carries an `@internal exported for unit-test - * coverage of the detection matrix` marker — this file is that coverage. It had - * none until now, which mattered: the only place the v3 wire choice was - * exercised was `integration/shared/v2-decrypt-compat.integration.test.ts`, and - * that suite needs live ZeroKMS credentials, so a regression here was invisible - * to `pnpm test`. - * - * Why a silent regression here is dangerous rather than merely wrong: if v3 - * detection broke, `resolveEqlVersion` would return `undefined` (the FFI's v2 - * default) instead of throwing, so a v3-schema client would quietly start - * writing v2 wire into `eql_v3_*` columns. Every v2-read compatibility test - * would keep passing, because reading v2 is exactly what a v2-mode client does - * natively. Pin the mapping directly. - * - * Credential-free by construction: `resolveEqlVersion` is pure, and inspects - * only `build()` output and the `buildColumnKeyMap` marker. - */ -import { describe, expect, it } from 'vitest' -import { resolveEqlVersion } from '@/encryption' -import { encryptedTable as encryptedTableV3, types as typesV3 } from '@/eql/v3' -// The deprecated v2 authoring builders remain for reading/migrating legacy data. -import { encryptedColumn, encryptedTable } from '@/schema' - -const usersV3 = encryptedTableV3('users_v3', { - email: typesV3.TextSearch('email'), -}) - -const ordersV3 = encryptedTableV3('orders_v3', { - total: typesV3.IntegerOrd('total'), -}) - -const usersV2 = encryptedTable('users_v2', { - email: encryptedColumn('email').equality(), -}) - -const documentsV2SteVec = encryptedTable('documents_v2', { - metadata: encryptedColumn('metadata').searchableJson(), -}) - -describe('resolveEqlVersion — wire format detection', () => { - it('resolves an all-v3 schema set to 3', () => { - expect(resolveEqlVersion([usersV3])).toBe(3) - }) - - it('resolves several v3 tables to 3', () => { - expect(resolveEqlVersion([usersV3, ordersV3])).toBe(3) - }) - - it('leaves a v2 scalar schema set on the FFI default by returning undefined', () => { - // NOT `2`: the FFI's own default is v2, and `undefined` is what the client - // passes through to mean "don't override it". - expect(resolveEqlVersion([usersV2])).toBeUndefined() - }) - - it('throws on a mixed v2 + v3 schema set — one client emits one wire format', () => { - expect(() => resolveEqlVersion([usersV3, usersV2])).toThrow( - /cannot mix EQL v2 and EQL v3 tables in one client/, - ) - }) - - it('throws on a mixed set regardless of schema order', () => { - expect(() => resolveEqlVersion([usersV2, usersV3])).toThrow( - /cannot mix EQL v2 and EQL v3 tables in one client/, - ) - }) -}) - -describe('resolveEqlVersion — legacy v2 searchable JSON', () => { - it('throws for a v2 ste_vec column, which protect-ffi 0.30 cannot emit', () => { - expect(() => resolveEqlVersion([documentsV2SteVec])).toThrow( - /searchableJson\(\) on the legacy EQL v2 schema is not supported/, - ) - }) - - it('still throws when an explicit eqlVersion is supplied', () => { - // The explicit escape hatch bypasses DETECTION, not validation — otherwise - // it would emit v3 data into an eql_v2 column. - expect(() => resolveEqlVersion([documentsV2SteVec], 2)).toThrow( - /searchableJson\(\) on the legacy EQL v2 schema is not supported/, - ) - }) -}) - -describe('resolveEqlVersion — explicit config.eqlVersion', () => { - // #772 review, finding 8. `EncryptionV3` used to force `eqlVersion: 3` over - // whatever the caller passed, with a comment saying it existed to stop - // exactly this. It is now a bare alias of `Encryption`, so the override is - // gone and nothing rejected the combination — the client built v2-wire over - // v3 concrete domains, which writes an `eql_v2_encrypted` payload into an - // `eql_v3_*` column. `resolveEqlVersion` already throws for a mixed set and - // for v2 SteVec; this was the one contradiction it let through. - it('rejects an explicit 2 over an all-v3 schema set', () => { - expect(() => resolveEqlVersion([usersV3], 2)).toThrow( - /cannot emit EQL v2 wire for a schema set that is entirely EQL v3/, - ) - }) - - it('rejects an explicit 2 over several v3 tables', () => { - expect(() => resolveEqlVersion([usersV3, ordersV3], 2)).toThrow( - /entirely EQL v3/, - ) - }) - - // The escape hatch itself survives, and this is the shape that actually uses - // it: `integration/shared/v2-decrypt-compat.integration.test.ts` mints its v2 - // fixtures from a v2 schema set. Nothing mints v2 wire from a v3 table. - it('still honours an explicit 2 over a v2 schema set', () => { - expect(resolveEqlVersion([usersV2], 2)).toBe(2) - }) - - it('honours an explicit 3 over a v2 schema set', () => { - expect(resolveEqlVersion([usersV2], 3)).toBe(3) - }) - - it('honours an explicit 3 over a v3 schema set', () => { - expect(resolveEqlVersion([usersV3], 3)).toBe(3) - }) - - // An empty set never reaches here through `Encryption` — the caller throws - // first — but the function is exported, so pin that it does not invent a - // wire version for a set with no evidence in it either way. - it('does not reject an explicit 2 for an empty schema set', () => { - expect(resolveEqlVersion([], 2)).toBe(2) - }) - - it('does not let an explicit version rescue a mixed schema set', () => { - // Mixing is unfixable by declaration: the two generations target different - // column types, so no single wire format serves both. - expect(() => resolveEqlVersion([usersV3, usersV2], 3)).toThrow( - /cannot mix EQL v2 and EQL v3 tables in one client/, - ) - }) -}) diff --git a/packages/stack/__tests__/schema-builders.test.ts b/packages/stack/__tests__/schema-builders.test.ts deleted file mode 100644 index a2bdc810b..000000000 --- a/packages/stack/__tests__/schema-builders.test.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - buildEncryptConfig, - EncryptedColumn, - EncryptedField, - EncryptedTable, - encryptedColumn, - encryptedField, - encryptedTable, -} from '@/schema' - -describe('schema builders', () => { - // ------------------------------------------------------- - // encryptedColumn - // ------------------------------------------------------- - describe('encryptedColumn', () => { - it('returns a EncryptedColumn with the correct name', () => { - const col = encryptedColumn('email') - expect(col).toBeInstanceOf(EncryptedColumn) - expect(col.getName()).toBe('email') - }) - - it('defaults castAs to string', () => { - const col = encryptedColumn('name') - const built = col.build() - expect(built.cast_as).toBe('string') - }) - - it('.dataType("string") sets castAs to string', () => { - const col = encryptedColumn('name').dataType('string') - expect(col.build().cast_as).toBe('string') - }) - - it('.dataType("number") sets castAs to number', () => { - const col = encryptedColumn('age').dataType('number') - expect(col.build().cast_as).toBe('number') - }) - - it('.dataType("boolean") sets castAs to boolean', () => { - const col = encryptedColumn('active').dataType('boolean') - expect(col.build().cast_as).toBe('boolean') - }) - - it('.dataType("date") sets castAs to date', () => { - const col = encryptedColumn('created').dataType('date') - expect(col.build().cast_as).toBe('date') - }) - - it('.dataType("bigint") sets castAs to bigint', () => { - const col = encryptedColumn('large').dataType('bigint') - expect(col.build().cast_as).toBe('bigint') - }) - - it('.dataType("json") sets castAs to json', () => { - const col = encryptedColumn('meta').dataType('json') - expect(col.build().cast_as).toBe('json') - }) - - it('.equality() adds a unique index', () => { - const col = encryptedColumn('email').equality() - const built = col.build() - expect(built.indexes).toHaveProperty('unique') - expect(built.indexes.unique).toEqual({ token_filters: [] }) - }) - - it('.equality() with token filters passes them through', () => { - const col = encryptedColumn('email').equality([{ kind: 'downcase' }]) - const built = col.build() - expect(built.indexes.unique).toEqual({ - token_filters: [{ kind: 'downcase' }], - }) - }) - - it('.freeTextSearch() adds a match index with defaults', () => { - const col = encryptedColumn('bio').freeTextSearch() - const built = col.build() - expect(built.indexes).toHaveProperty('match') - expect(built.indexes.match).toEqual({ - tokenizer: { kind: 'ngram', token_length: 3 }, - token_filters: [{ kind: 'downcase' }], - k: 6, - m: 2048, - include_original: true, - }) - }) - - it('.freeTextSearch() with custom opts overrides defaults', () => { - const col = encryptedColumn('bio').freeTextSearch({ - tokenizer: { kind: 'standard' }, - token_filters: [], - k: 10, - m: 4096, - include_original: false, - }) - const built = col.build() - expect(built.indexes.match).toEqual({ - tokenizer: { kind: 'standard' }, - token_filters: [], - k: 10, - m: 4096, - include_original: false, - }) - }) - - it('.orderAndRange() adds an ore index', () => { - const col = encryptedColumn('age').orderAndRange() - const built = col.build() - expect(built.indexes).toHaveProperty('ore') - expect(built.indexes.ore).toEqual({}) - }) - - it('.searchableJson() adds a ste_vec index and sets castAs to json', () => { - const col = encryptedColumn('metadata').searchableJson() - const built = col.build() - expect(built.cast_as).toBe('json') - expect(built.indexes).toHaveProperty('ste_vec') - expect(built.indexes.ste_vec).toEqual({ - prefix: 'enabled', - array_index_mode: 'all', - mode: 'standard', - }) - }) - - it('chaining multiple indexes: .equality().freeTextSearch().orderAndRange()', () => { - const col = encryptedColumn('email') - .equality() - .freeTextSearch() - .orderAndRange() - const built = col.build() - - expect(built.indexes).toHaveProperty('unique') - expect(built.indexes).toHaveProperty('match') - expect(built.indexes).toHaveProperty('ore') - expect(built.indexes.unique).toEqual({ token_filters: [] }) - expect(built.indexes.ore).toEqual({}) - expect(built.indexes.match).toBeDefined() - }) - - it('.build() produces the correct schema shape', () => { - const col = encryptedColumn('email') - .dataType('string') - .equality() - .orderAndRange() - const built = col.build() - - expect(built).toEqual({ - cast_as: 'string', - indexes: { - unique: { token_filters: [] }, - ore: {}, - }, - }) - }) - - it('.build() with no indexes produces empty indexes object', () => { - const col = encryptedColumn('raw') - const built = col.build() - expect(built).toEqual({ - cast_as: 'string', - indexes: {}, - }) - }) - }) - - // ------------------------------------------------------- - // encryptedTable - // ------------------------------------------------------- - describe('encryptedTable', () => { - it('creates a table with accessible column properties', () => { - const table = encryptedTable('users', { - email: encryptedColumn('email'), - }) - expect(table.email).toBeInstanceOf(EncryptedColumn) - }) - - it('table.email gives back the EncryptedColumn', () => { - const emailCol = encryptedColumn('email').equality() - const table = encryptedTable('users', { email: emailCol }) - expect(table.email).toBe(emailCol) - }) - - it('table.tableName is correct', () => { - const table = encryptedTable('users', { - email: encryptedColumn('email'), - }) - expect(table.tableName).toBe('users') - }) - - it('is an instance of EncryptedTable', () => { - const table = encryptedTable('users', { - email: encryptedColumn('email'), - }) - expect(table).toBeInstanceOf(EncryptedTable) - }) - - it('table.build() produces correct config structure', () => { - const table = encryptedTable('users', { - email: encryptedColumn('email').equality(), - age: encryptedColumn('age').dataType('number').orderAndRange(), - }) - const built = table.build() - - expect(built.tableName).toBe('users') - expect(built.columns).toEqual({ - email: { - cast_as: 'string', - indexes: { - unique: { token_filters: [] }, - }, - }, - age: { - cast_as: 'number', - indexes: { - ore: {}, - }, - }, - }) - }) - - it('table.build() rewrites ste_vec prefix for searchableJson columns', () => { - const table = encryptedTable('documents', { - metadata: encryptedColumn('metadata').searchableJson(), - }) - const built = table.build() - - expect(built.columns.metadata.cast_as).toBe('json') - expect(built.columns.metadata.indexes.ste_vec).toEqual({ - prefix: 'documents/metadata', - array_index_mode: 'all', - mode: 'standard', - }) - }) - - it('supports multiple columns', () => { - const table = encryptedTable('users', { - email: encryptedColumn('email').equality(), - name: encryptedColumn('name').freeTextSearch(), - age: encryptedColumn('age').dataType('number').orderAndRange(), - }) - - expect(table.email).toBeInstanceOf(EncryptedColumn) - expect(table.name).toBeInstanceOf(EncryptedColumn) - expect(table.age).toBeInstanceOf(EncryptedColumn) - }) - - it('exposes columnBuilders as a public, read-only map of the typed builders', () => { - const emailCol = encryptedColumn('email').equality() - const ageCol = encryptedColumn('age').dataType('number').orderAndRange() - const table = encryptedTable('users', { - email: emailCol, - age: ageCol, - }) - - // Consumers should be able to iterate the builders without reaching - // into the built TableDefinition (`table.build().columns`) or a - // private internal. - expect(Object.keys(table.columnBuilders).sort()).toEqual(['age', 'email']) - expect(table.columnBuilders.email).toBe(emailCol) - expect(table.columnBuilders.age).toBe(ageCol) - expect(table.columnBuilders).toBe(table.columnBuilders) - }) - }) - - // ------------------------------------------------------- - // buildEncryptConfig - // ------------------------------------------------------- - describe('buildEncryptConfig', () => { - it('produces { v: 1, tables: {...} } structure', () => { - const table = encryptedTable('users', { - email: encryptedColumn('email').equality(), - }) - const config = buildEncryptConfig(table) - - expect(config).toEqual({ - v: 1, - tables: { - users: { - email: { - cast_as: 'string', - indexes: { - unique: { token_filters: [] }, - }, - }, - }, - }, - }) - }) - - it('produces config with multiple tables', () => { - const users = encryptedTable('users', { - email: encryptedColumn('email').equality(), - }) - const products = encryptedTable('products', { - price: encryptedColumn('price').dataType('number').orderAndRange(), - }) - const config = buildEncryptConfig(users, products) - - expect(config.v).toBe(1) - expect(Object.keys(config.tables)).toHaveLength(2) - expect(config.tables).toHaveProperty('users') - expect(config.tables).toHaveProperty('products') - expect(config.tables.users).toHaveProperty('email') - expect(config.tables.products).toHaveProperty('price') - }) - - it('v is always 1', () => { - const table = encryptedTable('t', { - col: encryptedColumn('col'), - }) - const config = buildEncryptConfig(table) - expect(config.v).toBe(1) - }) - - it('config with searchableJson has correct ste_vec prefix', () => { - const docs = encryptedTable('documents', { - metadata: encryptedColumn('metadata').searchableJson(), - }) - const config = buildEncryptConfig(docs) - - expect(config.tables.documents.metadata.indexes.ste_vec).toEqual({ - prefix: 'documents/metadata', - array_index_mode: 'all', - mode: 'standard', - }) - }) - }) - - // ------------------------------------------------------- - // encryptedField (EncryptedField) - // ------------------------------------------------------- - describe('encryptedField', () => { - it('creates a EncryptedField', () => { - const value = encryptedField('field') - expect(value).toBeInstanceOf(EncryptedField) - }) - - it('returns correct name', () => { - const value = encryptedField('myField') - expect(value.getName()).toBe('myField') - }) - - it('defaults castAs to string', () => { - const value = encryptedField('field') - const built = value.build() - expect(built.cast_as).toBe('string') - }) - - it('.dataType("json").build() produces correct shape', () => { - const value = encryptedField('field').dataType('json') - const built = value.build() - expect(built).toEqual({ - cast_as: 'json', - indexes: {}, - }) - }) - - it('.dataType("number").build() produces correct shape', () => { - const value = encryptedField('field').dataType('number') - const built = value.build() - expect(built).toEqual({ - cast_as: 'number', - indexes: {}, - }) - }) - - it('.build() always has empty indexes', () => { - const value = encryptedField('field').dataType('string') - const built = value.build() - expect(built.indexes).toEqual({}) - }) - }) - - // ------------------------------------------------------- - // encryptedTable with nested EncryptedField columns - // ------------------------------------------------------- - describe('encryptedTable with EncryptedField', () => { - it('table.build() processes nested EncryptedField entries', () => { - const table = encryptedTable('users', { - profile: { - firstName: encryptedField('firstName'), - lastName: encryptedField('lastName').dataType('string'), - }, - }) - - const built = table.build() - expect(built.tableName).toBe('users') - expect(built.columns).toHaveProperty('firstName') - expect(built.columns).toHaveProperty('lastName') - expect(built.columns.firstName).toEqual({ - cast_as: 'string', - indexes: {}, - }) - }) - }) -}) diff --git a/packages/stack/__tests__/schema-v3.test-d.ts b/packages/stack/__tests__/schema-v3.test-d.ts deleted file mode 100644 index cd2911026..000000000 --- a/packages/stack/__tests__/schema-v3.test-d.ts +++ /dev/null @@ -1,370 +0,0 @@ -import { describe, expectTypeOf, it } from 'vitest' -import { Encryption, type EncryptionClient } from '@/encryption' -import type { - EncryptedBigintColumn, - EncryptedTextSearchColumn, - InferEncrypted, - InferPlaintext, -} from '@/eql/v3' -import { encryptedTable, types } from '@/eql/v3' -// v2 column builders — used to prove the v3 table type rejects a v2 column and -// to assert v2 backward-compat against the widened client types. -import { - encryptedColumn, - encryptedField, - encryptedTable as v2EncryptedTable, -} from '@/schema' -import type { Encrypted } from '@/types' - -describe('eql_v3 schema type inference', () => { - it('types.TextSearch returns an EncryptedTextSearchColumn', () => { - const col = types.TextSearch('email') - expectTypeOf(col).toEqualTypeOf() - }) - - it('types.Bigint returns an EncryptedBigintColumn (native bigint round-trip on protect-ffi 0.28)', () => { - const col = types.Bigint('large') - expectTypeOf(col).toEqualTypeOf() - }) - - it('encryptedTable exposes column builders as typed properties', () => { - const users = encryptedTable('users', { - email: types.TextSearch('email'), - }) - expectTypeOf(users.email).toEqualTypeOf() - expectTypeOf(users.tableName).toBeString() - }) - - it('rejects a v2 EncryptedColumn in a v3 table (nominal private-field mismatch)', () => { - encryptedTable('users', { - // @ts-expect-error - a v2 EncryptedColumn is not an EncryptedTextSearchColumn - email: encryptedColumn('email'), - }) - }) - - it('InferPlaintext maps each column to string', () => { - const users = encryptedTable('users', { - email: types.TextSearch('email'), - name: types.TextSearch('name'), - }) - type Plaintext = InferPlaintext - expectTypeOf().toEqualTypeOf<{ email: string; name: string }>() - }) - - it('InferEncrypted maps each column to Encrypted', () => { - const users = encryptedTable('users', { - email: types.TextSearch('email'), - }) - type Enc = InferEncrypted<typeof users> - expectTypeOf<Enc>().toEqualTypeOf<{ email: Encrypted }>() - }) - - it('InferPlaintext maps v3 concrete domains to plaintext TypeScript types', () => { - const metrics = encryptedTable('metrics', { - name: types.Text('name'), - age: types.Integer('age'), - active: types.Boolean('active'), - createdAt: types.Timestamp('created_at'), - score: types.Double('score'), - balance: types.BigintOrd('balance'), - }) - - type Plaintext = InferPlaintext<typeof metrics> - - expectTypeOf<Plaintext>().toEqualTypeOf<{ - name: string - age: number - active: boolean - createdAt: Date - score: number - balance: bigint - }>() - }) - - it('v3 domain classes remain nominal by literal domain definition', () => { - const date = types.Date('created_on') - const boolean = types.Boolean('active') - - expectTypeOf(date).not.toEqualTypeOf<typeof boolean>() - - // @ts-expect-error - storage-only boolean is not assignable to storage-only date - const invalid: typeof date = boolean - void invalid - }) -}) - -describe('eql_v3 client integration (type-level acceptance)', () => { - const v3users = encryptedTable('users', { - email: types.TextSearch('email'), - }) - - it('Encryption accepts a v3 schema', () => { - expectTypeOf(Encryption).toBeCallableWith({ schemas: [v3users] }) - }) - - it('encrypt accepts a v3 table + column', () => { - const client = {} as EncryptionClient - expectTypeOf(client.encrypt).toBeCallableWith('alice@example.com', { - table: v3users, - column: v3users.email, - }) - }) - - it('encrypt and encryptQuery accept bigint plaintext (native round-tripping landed in protect-ffi 0.28)', () => { - const client = {} as EncryptionClient - - expectTypeOf(client.encrypt).toBeCallableWith(1n, { - table: v3users, - column: v3users.email, - }) - - expectTypeOf(client.encryptQuery).toBeCallableWith(1n, { - table: v3users, - column: v3users.email, - }) - }) - - it('encryptQuery accepts a v3 table + column', () => { - const client = {} as EncryptionClient - expectTypeOf(client.encryptQuery).toBeCallableWith('alice@example.com', { - table: v3users, - column: v3users.email, - }) - }) - - it('decrypt accepts an Encrypted value (round-trip target type; schema-independent)', () => { - const client = {} as EncryptionClient - expectTypeOf(client.decrypt).toBeCallableWith({} as Encrypted) - }) - - it('BACKWARD COMPAT: v2 tables/columns still satisfy the widened types', () => { - const v2users = v2EncryptedTable('users', { - email: encryptedColumn('email').equality(), - }) - expectTypeOf(Encryption).toBeCallableWith({ schemas: [v2users] }) - const client = {} as EncryptionClient - expectTypeOf(client.encrypt).toBeCallableWith('alice@example.com', { - table: v2users, - column: v2users.email, - }) - // a v2 EncryptedColumn is STILL queryable (nominal arm of BuildableQueryColumn) - expectTypeOf(client.encryptQuery).toBeCallableWith('alice@example.com', { - table: v2users, - column: v2users.email, - }) - }) - - it('a non-queryable v2 EncryptedField is encryptable but NOT queryable', () => { - const v2usersWithField = v2EncryptedTable('users', { - profile: { email: encryptedField('email') }, - }) - const client = {} as EncryptionClient - - // POSITIVE: a field IS encryptable (storage path = BuildableColumn) - expectTypeOf(client.encrypt).toBeCallableWith('alice@example.com', { - table: v2usersWithField, - column: v2usersWithField.profile.email, - }) - - // NEGATIVE: a field is NOT queryable. The query path uses - // BuildableQueryColumn, which excludes EncryptedField (no indexes). If the - // query path were instead widened to BuildableColumn (the rejected - // Batch-2/3 design), this call would compile and only fail at runtime with - // "no indexes configured" — so this test guards against that re-widening. - // - // The mismatch is a DEEP object-literal property error, so tsc reports it on - // the `column:` line — the `@ts-expect-error` MUST sit directly above that - // line (not above the call), or you get TS2578 "unused directive" + the real - // error leaking. (Mirror of Task 4's v2-column-rejected test placement.) - client.encryptQuery('alice@example.com', { - table: v2usersWithField, - // @ts-expect-error - EncryptedField is not assignable to BuildableQueryColumn - column: v2usersWithField.profile.email, - }) - }) - - it('encryptQuery accepts queryable v3 columns with explicit capability metadata', () => { - const users = encryptedTable('users', { - emailEq: types.TextEq('email_eq'), - emailMatch: types.TextMatch('email_match'), - emailSearch: types.TextSearch('email_search'), - createdAt: types.TimestampOrd('created_at'), - }) - const client = {} as EncryptionClient - - expectTypeOf(client.encryptQuery).toBeCallableWith('alice@example.com', { - table: users, - column: users.emailEq, - }) - expectTypeOf(client.encryptQuery).toBeCallableWith('ali', { - table: users, - column: users.emailMatch, - queryType: 'freeTextSearch', - }) - expectTypeOf(client.encryptQuery).toBeCallableWith(new Date(), { - table: users, - column: users.createdAt, - queryType: 'orderAndRange', - }) - expectTypeOf(client.encryptQuery).toBeCallableWith('alice@example.com', { - table: users, - column: users.emailSearch, - queryType: 'equality', - }) - }) - - it('encryptQuery rejects storage-only v3 columns at compile time', () => { - const users = encryptedTable('users', { - email: types.Text('email'), - active: types.Boolean('active'), - }) - const client = {} as EncryptionClient - - client.encryptQuery('alice@example.com', { - table: users, - // @ts-expect-error - storage-only v3 text column is not queryable - column: users.email, - }) - - client.encryptQuery(true, { - table: users, - // @ts-expect-error - storage-only v3 boolean column is not queryable - column: users.active, - }) - }) -}) - -describe('eql_v3 model encryption inference', () => { - it('encryptModel and bulkEncryptModels infer encrypted fields from v3 tables', () => { - const users = encryptedTable('users', { - email: types.TextSearch('email'), - active: types.Boolean('active'), - }) - const client = {} as EncryptionClient - - const encryptedOne = client.encryptModel( - { id: 'u1', email: 'alice@example.com', active: true, untouched: 42 }, - users, - ) - expectTypeOf(encryptedOne).toEqualTypeOf< - import('@/encryption').EncryptModelOperation<{ - id: string - email: Encrypted - active: Encrypted - untouched: number - }> - >() - - const encryptedMany = client.bulkEncryptModels( - [{ id: 'u1', email: 'alice@example.com', active: true }], - users, - ) - expectTypeOf(encryptedMany).toEqualTypeOf< - import('@/encryption').BulkEncryptModelsOperation<{ - id: string - email: Encrypted - active: Encrypted - }> - >() - }) - - it('v3 encryptModel preserves unrelated and nullable fields', () => { - const users = encryptedTable('users', { - email: types.TextSearch('email'), - }) - const client = {} as EncryptionClient - - const encrypted = client.encryptModel( - { id: 'u1', email: null as string | null, untouched: 42 }, - users, - ) - - expectTypeOf(encrypted).toEqualTypeOf< - import('@/encryption').EncryptModelOperation<{ - id: string - email: Encrypted | null - untouched: number - }> - >() - }) - - it('encryptModel degrades gracefully for a bare BuildableTable-typed value (no brand)', () => { - // A table passed through a value/param annotated as the structural - // `BuildableTable` (no `_columnType` brand) cannot recover its literal - // column keys. It must degrade to the model unchanged — NOT mark every - // field `Encrypted`. Regression guard: `keyof BuildableTableColumns<...>` - // must resolve to `never` here, not `keyof never` (= string|number|symbol), - // which would wrongly encrypt all fields including `id` and `untouched`. - const usersConcrete = encryptedTable('users', { - email: types.TextSearch('email'), - }) - const table: import('@/types').BuildableTable = usersConcrete - const client = {} as EncryptionClient - - const encrypted = client.encryptModel( - { id: 'u1', email: 'alice@example.com', untouched: 42 }, - table, - ) - expectTypeOf(encrypted).toEqualTypeOf< - import('@/encryption').EncryptModelOperation<{ - id: string - email: string - untouched: number - }> - >() - }) - - it('model inference keys off the property name, not the DB column name (aliased columns)', () => { - // The column's DB name ('created_at') differs from the object property name - // ('occurredAt'). Model inference keys off the PROPERTY name, so `occurredAt` - // must become `Encrypted` while unrelated fields are preserved verbatim. - const events = encryptedTable('events', { - occurredAt: types.Timestamp('created_at'), - }) - const client = {} as EncryptionClient - - const encryptedOne = client.encryptModel( - { id: 'e1', occurredAt: new Date(), label: 'signup' }, - events, - ) - expectTypeOf(encryptedOne).toEqualTypeOf< - import('@/encryption').EncryptModelOperation<{ - id: string - occurredAt: Encrypted - label: string - }> - >() - - const encryptedMany = client.bulkEncryptModels( - [{ id: 'e1', occurredAt: new Date(), label: 'signup' }], - events, - ) - expectTypeOf(encryptedMany).toEqualTypeOf< - import('@/encryption').BulkEncryptModelsOperation<{ - id: string - occurredAt: Encrypted - label: string - }> - >() - }) - - it('v2 encryptModel inference still preserves non-schema fields after widening', () => { - const users = v2EncryptedTable('users', { - email: encryptedColumn('email').equality(), - }) - const client = {} as EncryptionClient - - const encrypted = client.encryptModel( - { id: 'u1', email: 'alice@example.com', age: 30 }, - users, - ) - - expectTypeOf(encrypted).toEqualTypeOf< - import('@/encryption').EncryptModelOperation<{ - id: string - email: Encrypted - age: number - }> - >() - }) -}) diff --git a/packages/stack/__tests__/schema-v3.test.ts b/packages/stack/__tests__/schema-v3.test.ts index 17c66b170..56f66f5e8 100644 --- a/packages/stack/__tests__/schema-v3.test.ts +++ b/packages/stack/__tests__/schema-v3.test.ts @@ -7,7 +7,7 @@ import { encryptedTable, types, } from '@/eql/v3' -import { encryptConfigSchema, encryptedColumn } from '@/schema' +import { encryptConfigSchema, encryptedColumn } from '@/schema/internal' import { type DomainSpec, typedEntries, V3_MATRIX } from './v3-matrix/catalog' describe('eql_v3 text_search column', () => { diff --git a/packages/stack/__tests__/test-kit-v2-fixtures.test.ts b/packages/stack/__tests__/test-kit-v2-fixtures.test.ts new file mode 100644 index 000000000..27cc0797a --- /dev/null +++ b/packages/stack/__tests__/test-kit-v2-fixtures.test.ts @@ -0,0 +1,73 @@ +import { + V3_MATRIX, + v2FixturePlan, + v2OpeIndexedDomains, + v2UndeclaredCastAs, +} from '@cipherstash/test-kit' +import { describe, expect, it } from 'vitest' + +/** + * The accounting half of the v2 fixture matrix — unit-tested, deliberately. + * + * These three checks are the mechanism that keeps the v2 read matrix honest: + * they turn "some domains were quietly skipped" into a red build. They are pure + * functions over `V3_MATRIX` — no network, no credentials, no FFI. + * + * They are duplicated here, rather than left only in + * `integration/shared/v2-decrypt-compat.integration.test.ts` which consumes the + * same plan, because that suite fails hard without live CipherStash credentials + * (`requireIntegrationEnv`) and will not even collect without them. A guard whose + * whole purpose is to fire when someone adds a domain is worth little if it only + * fires for the subset of contributors holding integration credentials — that is + * the silent-skip failure mode this repo already guards against elsewhere (see + * the `CS_IT_SUITE` glob guard in `integration/vitest.config.ts`). Here they run + * in `pnpm test`, for everyone, on every domain change. + * + * Sibling precedent: `test-kit-families.test.ts` / `test-kit-env.test.ts`. + */ +describe('v2 fixture plan accounting', () => { + const plan = v2FixturePlan() + + it('accounts for every catalog domain as either minted or deferred', () => { + const accounted = [ + ...plan.domains.map((domain) => domain.eqlType), + ...plan.deferred.map((domain) => domain.eqlType), + ].sort() + // The partition IS the coverage mechanism: a domain in neither set has been + // dropped, and one in both would be counted twice. + expect(accounted).toEqual(Object.keys(V3_MATRIX).sort()) + expect(plan.domains.length).toBeGreaterThan(0) + for (const { eqlType, reason } of plan.deferred) { + expect(reason, `${eqlType} is deferred with no reason`).not.toBe('') + } + }) + + /** + * The exclusions are hand-written so a NEW domain defaults to covered and + * fails loudly. This stops that list drifting from the rule it encodes: the + * deferred set must be exactly the `ope`-indexed domains (no such v2 payload + * can exist — EQL v2 scalars carry `hm`/`bf`/`ob`, never `op`) plus the one + * `ste_vec` domain the shipped client refuses to write in v2 mode. + */ + it('defers exactly the domains its written reasons cover', () => { + expect(plan.deferred.map((domain) => domain.eqlType).sort()).toEqual( + [...v2OpeIndexedDomains(), 'public.eql_v3_json_search'].sort(), + ) + }) + + /** + * Deferring a domain is normally free — decrypt reconstructs from `cast_as`, + * and every deferred domain shares its `cast_as` with a covered one. Deferring + * the LAST domain on an axis is not free, and would otherwise be invisible: + * the integration suite would still show ~100 green cases. + */ + it('loses no plaintext axis to a deferral without declaring it', () => { + expect(v2UndeclaredCastAs(plan)).toEqual([]) + // Pinned, not derived: the axes the v2 READ path is actually proven on. + // `json` is absent and declared unreachable — no v2 ste_vec fixture can be + // minted with cipherstash-client 0.42. + expect( + [...new Set(plan.cases.map((fixture) => fixture.castAs))].sort(), + ).toEqual(['bigint', 'boolean', 'date', 'number', 'string', 'timestamp']) + }) +}) diff --git a/packages/stack/__tests__/typed-client-init-wire-version.test.ts b/packages/stack/__tests__/typed-client-init-wire-version.test.ts deleted file mode 100644 index e535cc001..000000000 --- a/packages/stack/__tests__/typed-client-init-wire-version.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * The typed client's `init` passthrough must not reopen the door A-8 closed. - * - * A-7 added `init` to the typed EQL v3 client so that a caller holding it - * through its declared `EncryptionClient` type could not hit - * `TypeError: init is not a function`. But `EncryptionClient.init` takes its own - * `eqlVersion?: 2 | 3`, and forwards `undefined` straight to `newClient`, where - * the FFI's default is EQL **v2**. `resolveEqlVersion` — which refuses exactly - * that combination — is only ever consulted by `Encryption`, never by `init`. - * - * So a bare passthrough lets a v3 client be silently re-initialised into v2 wire - * while keeping the typed v3 surface: the same contradiction A-8 rejects at - * construction, reachable one method call later. - * - * const client = await Encryption({ schemas: [users] }) // eqlVersion: 3 - * await client.init({ encryptConfig }) // eqlVersion: undefined -> v2 - * - * `typed-client-nominal-parity.test.ts` cannot catch this: it stubs `init` out - * entirely and asserts only that it was called. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@cipherstash/protect-ffi', () => ({ - newClient: vi.fn(async () => ({ __mock: 'client' })), -})) - -import * as ffi from '@cipherstash/protect-ffi' -import type { EncryptionClient } from '@/encryption' -import { encryptedTable, types } from '@/encryption/v3' -import { Encryption } from '@/index' -import { buildEncryptConfig } from '@/schema' - -const users = encryptedTable('users', { email: types.TextSearch('email') }) - -// biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args -const newClientCalls = () => (ffi.newClient as any).mock.calls -const lastEqlVersion = () => newClientCalls().at(-1)[0].eqlVersion - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('typed client init keeps the v3 wire format', () => { - it('constructs at eqlVersion 3', async () => { - await Encryption({ schemas: [users] }) - - expect(lastEqlVersion()).toBe(3) - }) - - it('stays at eqlVersion 3 when re-initialised without one', async () => { - const client = await Encryption({ schemas: [users] }) - expect(lastEqlVersion()).toBe(3) - - await (client as unknown as EncryptionClient).init({ - encryptConfig: buildEncryptConfig(users), - }) - - // Without pinning, this is `undefined` — the FFI's v2 default — leaving a - // typed v3 surface over a client emitting v2 payloads into eql_v3_* columns. - expect(newClientCalls()).toHaveLength(2) - expect(lastEqlVersion()).toBe(3) - }) - - it('refuses an explicit eqlVersion 2 on re-init, as construction does', async () => { - const client = await Encryption({ schemas: [users] }) - - // `Encryption` throws for this combination, but `init` declares - // `Promise<Result<…>>` — the Result shape is contract, so this refuses with - // a failure rather than rejecting. Either way it never reaches the FFI. - const result = await (client as unknown as EncryptionClient).init({ - encryptConfig: buildEncryptConfig(users), - eqlVersion: 2, - }) - - expect(result.failure?.message).toMatch(/eqlVersion 2|eql_v3_\* domains/) - expect(newClientCalls()).toHaveLength(1) - }) - - it('returns the TYPED client, not the bare nominal one', async () => { - const client = await Encryption({ schemas: [users] }) - - const result = await (client as unknown as EncryptionClient).init({ - encryptConfig: buildEncryptConfig(users), - }) - - // `EncryptionClient.init` resolves `{ data: this }` — the nominal client. - // Reassigning from it (`client = (await client.init(c)).data`) is the - // natural idiom, and it must not silently drop the typed surface. - if (result.failure) throw new Error(result.failure.message) - expect( - typeof (result.data as { encryptQuery?: unknown }).encryptQuery, - ).toBe('function') - expect(result.data).toBe(client) - }) -}) diff --git a/packages/stack/__tests__/typed-client-nominal-parity.test.ts b/packages/stack/__tests__/typed-client-nominal-parity.test.ts deleted file mode 100644 index dc0bcc1c2..000000000 --- a/packages/stack/__tests__/typed-client-nominal-parity.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Runtime parity between the two clients `Encryption` can return. - * - * `Encryption` decides its return value with two discriminators that can - * disagree: overload resolution reads the `config` argument, while the runtime - * reads the `schemas` argument (`encryption/index.ts`, the `isV3Only && - * eqlVersion === 3` branch). Hoisting a config into a `ClientConfig`-typed - * variable is enough to split them — `ClientConfig.eqlVersion` is `2 | 3`, which - * is not assignable to the v3 overload's `eqlVersion?: 3`, so the call selects - * the NOMINAL overload while the runtime still hands back the TYPED client. - * - * No type-level design closes that: the runtime inspects values, the type - * inspects a static type that can be widened away. It ends when the EQL v2 - * removal collapses the two clients into one (#637). Until then the mismatch - * must not be able to crash, which means every member of `EncryptionClient` has - * to exist on the typed client at runtime. - */ -import { describe, expect, it, vi } from 'vitest' -import { Encryption, EncryptionClient } from '@/encryption' -import { encryptedTable, types } from '@/encryption/v3' -import type { ClientConfig } from '@/types' - -const users = encryptedTable('users', { email: types.TextSearch('email') }) - -/** - * Build a client without credentials by stubbing `init` to resolve to itself — - * `Encryption` only needs a successful `Result` to reach its return branch. - */ -async function buildWithStubbedInit(config: ClientConfig) { - const spy = vi - .spyOn(EncryptionClient.prototype, 'init') - .mockImplementation(async function (this: EncryptionClient) { - return { data: this } - } as never) - try { - return await Encryption({ schemas: [users], config }) - } finally { - spy.mockRestore() - } -} - -describe('typed client / nominal client runtime parity', () => { - it('a ClientConfig-typed variable types as nominal but returns the typed client', async () => { - const config: ClientConfig = {} - const client = await buildWithStubbedInit(config) - - // The static type here is `EncryptionClient`. The runtime disagrees. - expect('encryptQuery' in client).toBe(true) - expect(client).not.toBeInstanceOf(EncryptionClient) - }) - - it('exposes every EncryptionClient member, so the mismatch cannot crash', async () => { - const config: ClientConfig = {} - const client = await buildWithStubbedInit(config) - - const nominalMembers = Object.getOwnPropertyNames( - EncryptionClient.prototype, - ).filter((name) => name !== 'constructor') - - // `init` was the only member missing, which turned the type/runtime - // mismatch above into `TypeError: client.init is not a function` for - // anything holding the client through its declared `EncryptionClient` type. - const missing = nominalMembers.filter( - (name) => typeof (client as Record<string, unknown>)[name] !== 'function', - ) - expect(missing).toEqual([]) - }) - - it('delegates init to the underlying client', async () => { - const config: ClientConfig = {} - const client = await buildWithStubbedInit(config) - - const spy = vi - .spyOn(EncryptionClient.prototype, 'init') - .mockResolvedValue({ data: {} } as never) - - await (client as unknown as EncryptionClient).init({} as never) - expect(spy).toHaveBeenCalledTimes(1) - - spy.mockRestore() - }) -}) diff --git a/packages/stack/__tests__/typed-client-v3.test-d.ts b/packages/stack/__tests__/typed-client-v3.test-d.ts index 58419bd1b..17ca69f92 100644 --- a/packages/stack/__tests__/typed-client-v3.test-d.ts +++ b/packages/stack/__tests__/typed-client-v3.test-d.ts @@ -4,7 +4,6 @@ import type { EncryptionClient } from '@/encryption' // from src/encryption/v3.ts), exercising the re-export at the same time. import { encryptedTable, - typedClient, types, type V3DecryptedModel, type V3EncryptedModel, @@ -25,7 +24,7 @@ const other = encryptedTable('other', { weight: types.IntegerOrd('weight'), }) -const client = typedClient({} as EncryptionClient, users, other) +declare const client: EncryptionClient<readonly [typeof users, typeof other]> describe('typed v3 client — encrypt plaintext is pinned to the column domain', () => { it('accepts the matching plaintext type per domain', () => { @@ -90,6 +89,48 @@ describe('typed v3 client — encryptQuery constrains queryType to capabilities' column: users.note, }) }) + + it('keeps batch values correlated with their table and column', () => { + client.encryptQuery([ + { value: 'alice@example.com', table: users, column: users.email }, + { + value: new Date(), + table: users, + column: users.createdAt, + queryType: 'orderAndRange', + }, + ]) + + client.encryptQuery([ + // @ts-expect-error - a timestamp column requires a Date + { + value: 'not-a-date', + table: users, + column: users.createdAt, + }, + ]) + }) +}) + +describe('typed v3 client — bulk encrypt derives the column plaintext', () => { + it('accepts matching values and preserves nullable entries', () => { + client.bulkEncrypt( + [{ id: '1', plaintext: new Date() }, { plaintext: null }], + { table: users, column: users.createdAt }, + ) + }) + + it('rejects a value from the wrong domain', () => { + client.bulkEncrypt( + [ + { + // @ts-expect-error - timestamp bulk values must be Date or null + plaintext: 'not-a-date', + }, + ], + { table: users, column: users.createdAt }, + ) + }) }) describe('typed v3 client — model encrypt validates schema fields', () => { @@ -190,3 +231,81 @@ describe('typed v3 client — soundness', () => { }) }) }) + +declare const lockContext: { identityClaim: string[] } + +describe('typed v3 client — a lock context binds exactly once', () => { + it('offers .withLockContext() on an unbound decrypt operation', () => { + expectTypeOf( + client.decryptModel({ email: {} as Encrypted }, users).withLockContext, + ).toBeFunction() + expectTypeOf( + client.bulkDecryptModels([{ email: {} as Encrypted }], users) + .withLockContext, + ).toBeFunction() + }) + + it('drops .withLockContext() once bound positionally', () => { + const op = client.decryptModel( + { email: {} as Encrypted }, + users, + lockContext, + ) + // @ts-expect-error - already lock-bound; binding twice throws at runtime + op.withLockContext(lockContext) + + const bulk = client.bulkDecryptModels( + [{ email: {} as Encrypted }], + users, + lockContext, + ) + // @ts-expect-error - already lock-bound; binding twice throws at runtime + bulk.withLockContext(lockContext) + }) + + it('drops .withLockContext() once bound by chaining', () => { + const op = client + .decryptModel({ email: {} as Encrypted }, users) + .withLockContext(lockContext) + // @ts-expect-error - already lock-bound; binding twice throws at runtime + op.withLockContext(lockContext) + }) + + it('keeps .audit() available after binding', () => { + expectTypeOf( + client.decryptModel({ email: {} as Encrypted }, users, lockContext).audit, + ).toBeFunction() + }) +}) + +/** + * The overload split that makes a double bind a compile error must not also + * reject an OPTIONAL lock context. `decryptModel(row, table, session?.lc)` — + * where the context is `LockContextInput | undefined` — is the ordinary shape + * for code that decrypts identity-bound rows only for signed-in users. It + * compiled against the single optional parameter this replaced, and nothing + * about binding-once requires breaking it: `undefined` binds nothing. + */ +describe('typed v3 client — an optional lock context still type-checks', () => { + it('accepts LockContextInput | undefined positionally', () => { + expectTypeOf(client.decryptModel).toBeCallableWith( + { email: {} as Encrypted }, + users, + undefined, + ) + expectTypeOf(client.bulkDecryptModels).toBeCallableWith( + [{ email: {} as Encrypted }], + users, + undefined, + ) + }) + + it('accepts a union-typed context without narrowing at the call site', () => { + const maybe: typeof lockContext | undefined = undefined + expectTypeOf(client.decryptModel).toBeCallableWith( + { email: {} as Encrypted }, + users, + maybe, + ) + }) +}) diff --git a/packages/stack/__tests__/typed-client-v3.test.ts b/packages/stack/__tests__/typed-client-v3.test.ts index 3497021ec..563e49c96 100644 --- a/packages/stack/__tests__/typed-client-v3.test.ts +++ b/packages/stack/__tests__/typed-client-v3.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { EncryptionClient } from '@/encryption' -import { encryptedTable, typedClient, types } from '@/encryption/v3' +import { createEncryptionClient } from '@/encryption/client-v3' +import { encryptedTable, types } from '@/eql/v3' const table = encryptedTable('t', { when: types.Timestamp('when'), @@ -11,7 +12,7 @@ const table = encryptedTable('t', { }) /** - * A minimal operation stub resolving to a fixed `Result`. `typedClient` now + * A minimal operation stub resolving to a fixed `Result`. `createEncryptionClient` now * wraps the underlying decrypt op in a `MappedDecryptOperation` and calls * `.execute()` on it (rather than awaiting a bare promise), so the stub must be * operation-like: `.execute()` plus the chainable `.audit()` / `.withLockContext()` @@ -40,9 +41,9 @@ function fakeClient(data: Record<string, unknown>): EncryptionClient { } as unknown as EncryptionClient } -describe('typedClient — decrypt reconstruction', () => { +describe('createEncryptionClient — decrypt reconstruction', () => { it('reconstructs Date columns from cast_as', async () => { - const client = typedClient( + const client = createEncryptionClient( fakeClient({ when: '2020-01-02T03:04:05.000Z', note: 'hi', @@ -68,7 +69,10 @@ describe('typedClient — decrypt reconstruction', () => { }) it('leaves null column values untouched', async () => { - const client = typedClient(fakeClient({ when: null, note: null }), table) + const client = createEncryptionClient( + fakeClient({ when: null, note: null }), + table, + ) const result = await client.decryptModel({}, table) if (result.failure) return @@ -79,7 +83,10 @@ describe('typedClient — decrypt reconstruction', () => { }) it('leaves invalid date values untouched', async () => { - const client = typedClient(fakeClient({ when: 'not-a-date' }), table) + const client = createEncryptionClient( + fakeClient({ when: 'not-a-date' }), + table, + ) const result = await client.decryptModel({}, table) if (result.failure) return @@ -99,7 +106,7 @@ describe('typedClient — decrypt reconstruction', () => { untouched: true, }, } - const client = typedClient(fakeClient(decrypted), nested) + const client = createEncryptionClient(fakeClient(decrypted), nested) const result = await client.decryptModel({}, nested) if (result.failure) return @@ -115,7 +122,7 @@ describe('typedClient — decrypt reconstruction', () => { }) it('reconstructs each row for bulkDecryptModels', async () => { - const client = typedClient( + const client = createEncryptionClient( fakeClient({ when: '2021-06-01T00:00:00.000Z', note: 'x' }), table, ) @@ -136,14 +143,14 @@ describe('typedClient — decrypt reconstruction', () => { }), } as unknown as EncryptionClient - const client = typedClient(failing, table) + const client = createEncryptionClient(failing, table) const result = await client.decryptModel({}, table) expect(result.failure).toBeTruthy() }) it('fails with a DecryptionError when given a table it was not initialized with', async () => { const other = encryptedTable('other', { x: types.Text('x') }) - const client = typedClient(fakeClient({ when: null }), table) + const client = createEncryptionClient(fakeClient({ when: null }), table) const single = await client.decryptModel({}, other as never) expect(single.failure?.type).toBe('DecryptionError') @@ -164,7 +171,7 @@ describe('typedClient — decrypt reconstruction', () => { }) expect(sameTableRebuilt).not.toBe(table) - const client = typedClient( + const client = createEncryptionClient( fakeClient({ when: '2020-01-02T03:04:05.000Z', note: 'hi' }), table, ) @@ -184,7 +191,7 @@ describe('typedClient — decrypt reconstruction', () => { // They must decrypt without throwing — degrading to nominal behaviour (no date // reconstruction) — not dereference `undefined.tableName`. it('tolerates a one-arg (nominal-style) decryptModel call with no table', async () => { - const client = typedClient( + const client = createEncryptionClient( fakeClient({ when: '2020-01-02T03:04:05.000Z', note: 'hi' }), table, ) @@ -208,7 +215,7 @@ describe('typedClient — decrypt reconstruction', () => { }) it('tolerates a one-arg (nominal-style) bulkDecryptModels call with no table', async () => { - const client = typedClient( + const client = createEncryptionClient( fakeClient({ when: '2021-06-01T00:00:00.000Z', note: 'x' }), table, ) diff --git a/packages/stack/__tests__/types-public-surface.test-d.ts b/packages/stack/__tests__/types-public-surface.test-d.ts index 7bd458908..68d3ac5c6 100644 --- a/packages/stack/__tests__/types-public-surface.test-d.ts +++ b/packages/stack/__tests__/types-public-surface.test-d.ts @@ -1,42 +1,120 @@ +/** + * Regression guard for the public `@cipherstash/stack/types` entrypoint + * (`src/types-public.ts`). + * + * That file is a hand-maintained allowlist of `export type { … } from '@/types'`. + * Nothing derives it and nothing checks it, so its contents drift by a one-line + * edit in either direction — and both directions are silent: + * + * - REMOVING a name breaks consumers who legitimately need to NAME a type that + * appears in a public signature (declaring a variable before the `await`, + * typing an adapter's parameter). `@cipherstash/stack-drizzle`, + * `@cipherstash/stack-supabase` and `@cipherstash/prisma-next` all import from + * this subpath. + * - ADDING a name is how the EQL v2 authoring surface comes back. `src/types.ts` + * still declares v2-shaped types — the client must keep DECRYPTING v2 payloads, + * so the v2 builders survive as internals — and they sit in the same module the + * allowlist re-exports from. Publishing one is a single line. + * + * Importing a name a module does not export fails typecheck, so both halves are + * expressible here: the positive block compiling proves the surface is intact, + * and each `@ts-expect-error` failing to find an error proves a v2 name leaked + * back onto it. Run with `pnpm --filter @cipherstash/stack test:types`. + * + * The predecessor of this file was deleted alongside the v2 authoring surface in + * `a3830f0d` (#815) and not replaced, which left `./types` with no guard at all. + */ + import { describe, expectTypeOf, it } from 'vitest' -// Regression guard for the public `@cipherstash/stack/types` entrypoint -// (src/types-public.ts). The structural builder contracts and the -// `encryptModel` / `bulkEncryptModels` return-type mapper appear in PUBLIC -// return positions (encryption/index.ts), so consumers must be able to NAME -// them from the public path. Importing a member that is not re-exported fails -// typecheck — so this file compiling green proves the surface is complete. import type { - BuildableColumn, - BuildableQueryColumn, - BuildableTable, - BuildableTableColumns, - BuildableV3QueryableColumn, + AuthStrategy, + ClientConfig, + Decrypted, Encrypted, - EncryptedFromBuildableTable, + EncryptedQueryResult, + EncryptionClientConfig, + EncryptOptions, + EncryptQueryOptions, + QueryTypeName, + ScalarQueryTerm, } from '@/types-public' +import { queryTypes } from '@/types-public' + +// --------------------------------------------------------------------------- +// The v2 residue that must NOT be re-exported. +// +// Each directive below is load-bearing in the unusual direction: it fails when +// there is NO error, i.e. when the name HAS become public again. These are the +// exact specifiers `a3830f0d` deleted from the allowlist. +// +// They are written as `import(...)` type nodes rather than +// `export type { X } from '@/types-public'` on purpose: Biome's formatter merges +// adjacent re-exports of the same module into one statement, which would collapse +// all three onto a single line under a single directive. One directive covering +// three specifiers is satisfied by ANY one of them erroring, so two names could +// leak back with the guard still green. One declaration per name keeps the +// directives one-to-one. +// --------------------------------------------------------------------------- + +// `BuildableQueryColumn`'s first arm is the v2 `EncryptedColumn` class (see +// `src/types.ts`). It stays internal: it is the parameter type of the +// `inferIndexType` / `validateIndexType` helpers, which are still exercised +// against v2 columns, but nothing public should be able to name a union with a +// v2 builder in it. +// @ts-expect-error - v2 residue, internal-only +export type NoQueryColumnArm = import('@/types-public').BuildableQueryColumn + +// `EncryptedFromSchema` keys entirely off `EncryptedColumn | EncryptedField` — +// the v2 builders — so it is meaningless for a v3 schema. The v3 equivalent is +// `EncryptedFromBuildableTable`, also internal. +// +// The type arguments are REQUIRED for this guard to discriminate. Written bare, +// the reference errors with "requires 2 type argument(s)" whether or not the +// name is exported, so the directive is always consumed and the assertion is +// vacuous — it passed while the name was public. Applying the type arguments +// leaves TS2694 ("no exported member") as the only possible error. +// @ts-expect-error - v2-only model mapper, internal-only +export type NoV2ModelMapper = import('@/types-public').EncryptedFromSchema< + unknown, + never +> + +// `SearchTerm` is the v2-era spelling of a query term (it carries +// `BuildableQueryColumn` directly); `ScalarQueryTerm` is the shape `encryptQuery` +// actually takes and is the one exported above. +// @ts-expect-error - superseded by ScalarQueryTerm, internal-only +export type NoV2SearchTerm = import('@/types-public').SearchTerm describe('public @cipherstash/stack/types surface', () => { - it('exposes the structural builder contracts', () => { - // A v3 queryable column IS a BuildableColumn (interface extension). - expectTypeOf<BuildableV3QueryableColumn>().toMatchTypeOf<BuildableColumn>() - // The query-column union is nameable and non-trivial. - expectTypeOf<BuildableQueryColumn>().not.toBeNever() - // The client table contract is nameable. - expectTypeOf<BuildableTable['tableName']>().toBeString() + it('keeps the config types nameable', () => { + // Adapters declare these before they have a client. `ClientConfig` must not + // collapse to `never` — `eqlVersion?: never` on a v3-only config makes an + // over-eager narrowing plausible. + expectTypeOf<ClientConfig>().not.toBeNever() + expectTypeOf<EncryptionClientConfig>().not.toBeNever() + expectTypeOf<AuthStrategy>().not.toBeNever() }) - it('exposes EncryptedFromBuildableTable (the encryptModel return mapper)', () => { - interface Users extends BuildableTable { - readonly _columnType: { email: unknown } - } - type Row = { id: number; email: string } - type Enc = EncryptedFromBuildableTable<Row, Users> + it('keeps the payload and model types nameable', () => { + expectTypeOf<Encrypted>().not.toBeNever() + expectTypeOf< + Decrypted<{ email: Encrypted }>['email'] + >().toEqualTypeOf<string>() + }) - // Schema-column fields become Encrypted; passthrough fields keep their type. - expectTypeOf<Enc['email']>().toEqualTypeOf<Encrypted>() - expectTypeOf<Enc['id']>().toEqualTypeOf<number>() + it('keeps the encryptQuery option and result types nameable', () => { + // The types on `encryptQuery`'s public signature. A caller building terms in + // a helper function has to name these. + expectTypeOf<EncryptOptions>().not.toBeNever() + expectTypeOf<EncryptQueryOptions>().not.toBeNever() + expectTypeOf<ScalarQueryTerm>().toMatchTypeOf<EncryptQueryOptions>() + expectTypeOf<EncryptedQueryResult>().not.toBeNever() + }) - // The column-map helper is nameable too. - expectTypeOf<keyof BuildableTableColumns<Users>>().toEqualTypeOf<'email'>() + it('exports queryTypes as a runtime value whose keys are QueryTypeName', () => { + // Referenced as a VALUE (not `typeof`) so this stays a runtime import under + // `verbatimModuleSyntax` — `./types` is the only entry that publishes it. + expectTypeOf(queryTypes.equality).toEqualTypeOf<'equality'>() + expectTypeOf<keyof typeof queryTypes>().toEqualTypeOf<QueryTypeName>() }) }) diff --git a/packages/stack/__tests__/types.test-d.ts b/packages/stack/__tests__/types.test-d.ts deleted file mode 100644 index 0be34499b..000000000 --- a/packages/stack/__tests__/types.test-d.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { describe, expectTypeOf, it } from 'vitest' -import type { EncryptionClient } from '@/encryption' -import type { - EncryptedColumn, - EncryptedField, - EncryptedTable, - EncryptedTableColumn, - InferEncrypted, - InferPlaintext, -} from '@/schema' -import { encryptedColumn, encryptedTable } from '@/schema' -import type { - Decrypted, - DecryptedFields, - Encrypted, - EncryptedFields, - EncryptedFromSchema, - EncryptedReturnType, - KeysetIdentifier, - OtherFields, - QueryTypeName, -} from '@/types' - -describe('Type inference', () => { - it('encryptedTable returns ProtectTable with column access', () => { - const table = encryptedTable('users', { - email: encryptedColumn('email'), - }) - expectTypeOf(table.email).toMatchTypeOf<EncryptedColumn>() - expectTypeOf(table.tableName).toBeString() - }) - - it('encryptedTable is also a ProtectTable', () => { - const table = encryptedTable('users', { - email: encryptedColumn('email'), - }) - expectTypeOf(table).toMatchTypeOf< - EncryptedTable<{ email: EncryptedColumn }> - >() - }) - - it('encryptedColumn returns ProtectColumn', () => { - const col = encryptedColumn('email') - expectTypeOf(col).toMatchTypeOf<EncryptedColumn>() - }) - - it('encryptedColumn().dataType() returns ProtectColumn (for chaining)', () => { - const col = encryptedColumn('age').dataType('number') - expectTypeOf(col).toMatchTypeOf<EncryptedColumn>() - }) - - it('encryptedColumn().equality() returns ProtectColumn (for chaining)', () => { - const col = encryptedColumn('email').equality() - expectTypeOf(col).toMatchTypeOf<EncryptedColumn>() - }) - - it('Decrypted<T> maps Encrypted fields to string', () => { - type Model = { email: Encrypted; name: string } - type Result = Decrypted<Model> - expectTypeOf<Result>().toMatchTypeOf<{ email: string; name: string }>() - }) - - it('EncryptedFields<T> extracts only Encrypted fields', () => { - type Model = { email: Encrypted; name: string; age: number } - type Result = EncryptedFields<Model> - expectTypeOf<Result>().toMatchTypeOf<{ email: Encrypted }>() - }) - - it('OtherFields<T> extracts non-Encrypted fields', () => { - type Model = { email: Encrypted; name: string; age: number } - type Result = OtherFields<Model> - expectTypeOf<Result>().toMatchTypeOf<{ name: string; age: number }>() - }) - - it('DecryptedFields<T> maps Encrypted fields to string', () => { - type Model = { email: Encrypted; name: string } - type Result = DecryptedFields<Model> - expectTypeOf<Result>().toMatchTypeOf<{ email: string }>() - }) - - it('KeysetIdentifier is a union of name or id', () => { - expectTypeOf<{ name: string }>().toMatchTypeOf<KeysetIdentifier>() - expectTypeOf<{ id: string }>().toMatchTypeOf<KeysetIdentifier>() - }) - - it('QueryTypeName includes expected values', () => { - expectTypeOf<'equality'>().toMatchTypeOf<QueryTypeName>() - expectTypeOf<'freeTextSearch'>().toMatchTypeOf<QueryTypeName>() - expectTypeOf<'orderAndRange'>().toMatchTypeOf<QueryTypeName>() - expectTypeOf<'searchableJson'>().toMatchTypeOf<QueryTypeName>() - expectTypeOf<'steVecSelector'>().toMatchTypeOf<QueryTypeName>() - expectTypeOf<'steVecValueSelector'>().toMatchTypeOf<QueryTypeName>() - expectTypeOf<'steVecTerm'>().toMatchTypeOf<QueryTypeName>() - }) - - it('EncryptedReturnType includes expected values', () => { - expectTypeOf<'eql'>().toMatchTypeOf<EncryptedReturnType>() - expectTypeOf<'composite-literal'>().toMatchTypeOf<EncryptedReturnType>() - expectTypeOf<'escaped-composite-literal'>().toMatchTypeOf<EncryptedReturnType>() - }) - - it('InferPlaintext maps ProtectColumn keys to string', () => { - const table = encryptedTable('users', { - email: encryptedColumn('email'), - name: encryptedColumn('name'), - }) - type Plaintext = InferPlaintext<typeof table> - expectTypeOf<Plaintext>().toMatchTypeOf<{ email: string; name: string }>() - }) - - it('InferEncrypted maps ProtectColumn keys to Encrypted', () => { - const table = encryptedTable('users', { - email: encryptedColumn('email'), - }) - type Enc = InferEncrypted<typeof table> - expectTypeOf<Enc>().toMatchTypeOf<{ email: Encrypted }>() - }) - - it('EncryptedFromSchema maps schema fields to Encrypted, leaves others unchanged', () => { - type User = { id: string; email: string; createdAt: Date } - type Schema = { email: EncryptedColumn } - type Result = EncryptedFromSchema<User, Schema> - expectTypeOf<Result>().toEqualTypeOf<{ - id: string - email: Encrypted - createdAt: Date - }>() - }) - - it('EncryptedFromSchema with widened EncryptedTableColumn degrades to T', () => { - type User = { id: string; email: string } - type Result = EncryptedFromSchema<User, EncryptedTableColumn> - // When S is the wide EncryptedTableColumn, S[K] is the full union, not EncryptedColumn alone. - // The conditional [S[K]] extends [EncryptedColumn | EncryptedField] fails, so fields stay as-is. - expectTypeOf<Result>().toEqualTypeOf<{ id: string; email: string }>() - }) - - it('Decrypted reverses EncryptedFromSchema correctly', () => { - type User = { id: string; email: string; createdAt: Date } - type Schema = { email: EncryptedColumn } - type EncryptedUser = EncryptedFromSchema<User, Schema> - type DecryptedUser = Decrypted<EncryptedUser> - expectTypeOf<DecryptedUser>().toMatchTypeOf<{ - id: string - email: string - createdAt: Date - }>() - }) - - it('encryptModel infers schema-aware return types from table argument', async () => { - const users = encryptedTable('users', { - name: encryptedColumn('name').equality(), - email: encryptedColumn('email').freeTextSearch(), - }) - - const client = {} as EncryptionClient - - const result = await client.encryptModel( - { name: 'John', email: 'john@example.com', age: 30 }, - users, - ) - - if (!result.failure) { - // Schema fields should be Encrypted - expectTypeOf(result.data.name).toEqualTypeOf<Encrypted>() - expectTypeOf(result.data.email).toEqualTypeOf<Encrypted>() - // Non-schema fields should keep their original type - expectTypeOf(result.data.age).toEqualTypeOf<number>() - } - }) -}) diff --git a/packages/stack/__tests__/v3-matrix/matrix-audit.test-d.ts b/packages/stack/__tests__/v3-matrix/matrix-audit.test-d.ts index e3b56d954..c835247b0 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-audit.test-d.ts +++ b/packages/stack/__tests__/v3-matrix/matrix-audit.test-d.ts @@ -10,11 +10,10 @@ */ import { describe, expectTypeOf, it } from 'vitest' import type { EncryptionClient } from '@/encryption' -import { encryptedTable, typedClient, types } from '@/encryption/v3' +import { encryptedTable, types } from '@/encryption/v3' const users = encryptedTable('u', { email: types.TextEq('email') }) -declare const client: EncryptionClient -const typed = typedClient(client, users) +declare const typed: EncryptionClient<readonly [typeof users]> describe('v3 typed client audit/lock-context chainability (types)', () => { it('exposes .audit() and .withLockContext() on the encrypt operation', () => { diff --git a/packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts b/packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts index 0024902d6..654f6a137 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts +++ b/packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts @@ -37,7 +37,7 @@ vi.mock('@cipherstash/protect-ffi', () => ({ })) import * as ffi from '@cipherstash/protect-ffi' -import type { EncryptionClientFor } from '@/encryption/v3' +import type { EncryptionClient } from '@/encryption/v3' import { encryptedTable, types } from '@/encryption/v3' const users = encryptedTable('users', { @@ -70,8 +70,8 @@ function unwrap(result: any) { const lastOpts = (fn: any) => fn.mock.calls.at(-1)[1] // `Encryption` returns the typed client directly for an all-v3 schema set (the -// collapse of `EncryptionV3`), so there is no separate `typedClient` wrap here. -let typed: EncryptionClientFor<readonly [typeof users]> +// collapse of `Encryption`), so there is no separate `typedClient` wrap here. +let typed: EncryptionClient<readonly [typeof users]> let prevWorkspaceCrn: string | undefined beforeEach(async () => { diff --git a/packages/stack/__tests__/v3-only-public-surface.test.ts b/packages/stack/__tests__/v3-only-public-surface.test.ts new file mode 100644 index 000000000..21e5cf37d --- /dev/null +++ b/packages/stack/__tests__/v3-only-public-surface.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' +import { createEncryptionClient } from '@/encryption/client-v3' +import * as v3 from '@/encryption/v3' +import { encryptedTable, types } from '@/eql/v3' +import * as root from '@/index' +import * as schema from '@/schema' +import packageJson from '../package.json' + +const users = encryptedTable('users', { + email: types.TextEq('email'), +}) + +describe('v3-only public surface', () => { + it('does not export legacy client aliases or schema builders', () => { + expect(v3).not.toHaveProperty('EncryptionV3') + expect(v3).not.toHaveProperty('typedClient') + expect(root).not.toHaveProperty('encryptedColumn') + expect(root).not.toHaveProperty('encryptedField') + // `encryptedTable` is the one the root entry actually shipped: before + // `a3830f0d`, `src/index.ts` carried + // `export { encryptedColumn, encryptedField, encryptedTable } from '@/schema'` + // and the diff replaced that whole line with a type-only export. All three + // names must stay off the root — `encryptedTable` is the v2 table builder + // there, and re-adding it would restore v2 authoring on the default entry + // under a name that reads identically to the v3 builder callers import from + // `@cipherstash/stack/v3`. Asserting only the other two would have missed it. + expect(root).not.toHaveProperty('encryptedTable') + expect(schema).not.toHaveProperty('encryptedColumn') + expect(schema).not.toHaveProperty('encryptedTable') + }) + + it('removes the client subpath export', () => { + expect(packageJson.exports).not.toHaveProperty('./client') + expect(packageJson.typesVersions['*']).not.toHaveProperty('client') + }) + + /** + * The stale-map hazard #815 asked to close: a client re-initialised with a + * different encrypt config would keep the reconstructor map and unknown-table + * guard derived from the schema tuple captured at construction. A table added + * by the re-init would then encrypt successfully and fail to decrypt — a + * silent asymmetry, since both halves report success right up to the point + * the plaintext comes back wrong-shaped. + * + * It is closed structurally: config and reconstructors are derived from the + * same tuple in the same call, and nothing re-derives either afterwards. This + * pins that. Re-adding an `init` passthrough — the shape that carried the + * hazard before `a3830f0d` removed the typed-client surface — fails here. + * + * The assertion is exact equality rather than a denylist of suspect names. + * `createEncryptionClient` returns a plain object literal + * (`src/encryption/client-v3.ts`), so the member set is fully knowable, and + * the hazard is "a method that re-derives the schema-dependent state" — not + * "a method called `init`". A denylist only catches spellings someone thought + * of in advance; `withSchemas()`, `update()` or `setEncryptConfig()` would all + * carry the same stale-map hazard and all pass a name-by-name check. Exact + * equality fails on ANY added member, which forces the decision back through + * review. Update this list deliberately when the client surface changes. + */ + it('exposes no re-initialization path that could outlive the schema-derived maps', () => { + // Annotated so the static type is pinned alongside the runtime member set — + // a member added to `EncryptionClient<S>` but not to the object literal (or + // vice versa) is as much a regression as one added to both. + const client: EncryptionClient<readonly [typeof users]> = + createEncryptionClient({} as never, users) + + expect(Object.keys(client).sort()).toEqual([ + 'bulkDecrypt', + 'bulkDecryptModels', + 'bulkEncrypt', + 'bulkEncryptModels', + 'decrypt', + 'decryptModel', + 'encrypt', + 'encryptModel', + 'encryptQuery', + 'getEncryptConfig', + ]) + }) +}) diff --git a/packages/stack/__tests__/wasm-inline-column-name.test.ts b/packages/stack/__tests__/wasm-inline-column-name.test.ts index ee03ecf17..36e89f9f4 100644 --- a/packages/stack/__tests__/wasm-inline-column-name.test.ts +++ b/packages/stack/__tests__/wasm-inline-column-name.test.ts @@ -18,18 +18,9 @@ vi.mock('@cipherstash/protect-ffi/wasm-inline', () => ({ })) import { types } from '../src/eql/v3' -import { encryptedColumn, encryptedField } from '../src/schema' import { getColumnName } from '../src/wasm-inline' describe('wasm-inline getColumnName', () => { - it('returns the name for a v2 EncryptedColumn', () => { - expect(getColumnName(encryptedColumn('email'))).toBe('email') - }) - - it('returns the name for a v2 EncryptedField', () => { - expect(getColumnName(encryptedField('profile'))).toBe('profile') - }) - it('returns the name for a v3 EncryptedTextSearchColumn (structural, no instanceof)', () => { // Regression: widening EncryptOptions.column to the structural // BuildableColumn made v3 columns type-check at the wasm-inline encrypt diff --git a/packages/stack/__tests__/wasm-inline-query.test.ts b/packages/stack/__tests__/wasm-inline-query.test.ts index 90e041b47..b9f7724b3 100644 --- a/packages/stack/__tests__/wasm-inline-query.test.ts +++ b/packages/stack/__tests__/wasm-inline-query.test.ts @@ -39,6 +39,8 @@ const users = encryptedTable('users', { bio: types.TextSearch('bio'), // IntegerOrd → ope only (the OPE ordering flavour, no unique) age: types.IntegerOrd('age'), + // IntegerOrdOre → ore only (the block-ORE ordering flavour, no unique) + score: types.IntegerOrdOre('score'), // Json → ste_vec prefs: types.Json('prefs'), }) @@ -124,6 +126,37 @@ describe('WasmEncryptionClient.encryptQuery', () => { ) }) + it("keeps 'ore' for an _ord_ore domain (the other ordering flavour)", async () => { + // `orderingForEqlType` splits on the domain name: `_ord_ore` stays block-ORE + // where `_ord` is CLLW-OPE. Covering one flavour only lets a regression in + // that split hide behind the other. + const c = await client() + await c.encryptQuery(42, { + table: users, + column: users.score, + queryType: 'orderAndRange', + }) + expect(ffi.encryptQuery).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ indexType: 'ore' }), + ) + }) + + it('answers equality through the ore index on an _ord_ore column', async () => { + // The `indexes.ore` branch of `equalityOrderingIndex`; the test above it + // covers the `indexes.ope` branch. + const c = await client() + await c.encryptQuery(42, { + table: users, + column: users.score, + queryType: 'equality', + }) + expect(ffi.encryptQuery).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ indexType: 'ore' }), + ) + }) + it('rejects a queryType the column is not indexed for', async () => { const c = await client() await expect( diff --git a/packages/stack/__tests__/wasm-inline-schemas.test-d.ts b/packages/stack/__tests__/wasm-inline-schemas.test-d.ts new file mode 100644 index 000000000..6643b8ba6 --- /dev/null +++ b/packages/stack/__tests__/wasm-inline-schemas.test-d.ts @@ -0,0 +1,95 @@ +/** + * The WASM `Encryption` factory must accept the same schema-array shapes the + * native one does (#815 review). + * + * A-4 (`fix(stack)!: accept schema arrays that are not literals`) widened the + * native factory from a mutable non-empty tuple to `readonly AnyV3Table[]`, + * because `schemas.map(...)` results and `readonly` arrays are what real + * callers hold. The WASM twin kept the tuple, so the two entries disagreed + * about the same call — while their runtimes agree exactly (`!schemas.length`). + */ +import { describe, expectTypeOf, it } from 'vitest' +import { type AnyV3Table, encryptedTable, types } from '@/eql/v3' +import { + Encryption, + type WasmEncryptionClient, + type WasmEncryptionConfig, +} from '@/wasm-inline' + +const users = encryptedTable('users', { + email: types.TextEq('email'), +}) + +const config = { + workspaceCrn: 'crn', + accessKey: 'key', + clientId: 'id', + clientKey: 'client-key', +} + +describe('wasm-inline Encryption schema typing', () => { + it('accepts a literal tuple', async () => { + expectTypeOf( + await Encryption({ schemas: [users], config }), + ).toEqualTypeOf<WasmEncryptionClient>() + }) + + it('accepts a widened array, as the native entry does', async () => { + const widened: AnyV3Table[] = [users] + expectTypeOf( + await Encryption({ schemas: widened, config }), + ).toEqualTypeOf<WasmEncryptionClient>() + }) + + it('accepts a readonly array, as the native entry does', async () => { + const frozen: readonly AnyV3Table[] = [users] + expectTypeOf( + await Encryption({ schemas: frozen, config }), + ).toEqualTypeOf<WasmEncryptionClient>() + }) + + it('accepts a generic caller that is itself parameterised over its schemas', async () => { + async function make<S extends readonly [AnyV3Table, ...AnyV3Table[]]>( + schemas: S, + ) { + return Encryption({ schemas, config }) + } + + expectTypeOf(await make([users])).toEqualTypeOf<WasmEncryptionClient>() + }) + + it('still rejects an empty literal', () => { + // @ts-expect-error - at least one table is required + Encryption({ schemas: [], config }) + }) +}) + +/** + * The exported config type must not launder an empty schema set. + * + * Widening `WasmEncryptionConfig.schemas` to `readonly AnyV3Table[]` so the + * factory would accept `.map()` results also made the EXPORTED type unable to + * prove non-emptiness: `const cfg: WasmEncryptionConfig = { schemas: [], config }` + * compiled, and `Encryption(cfg)` compiled too, because overload 2's + * `NonEmptyV3<readonly AnyV3Table[]>` resolves `S['length']` to `number` rather + * than `0`. Only the direct literal was rejected. A config object built once and + * passed around — the shape this type exists to serve — therefore reached the + * runtime throw with a clean typecheck. + * + * Non-emptiness belongs on the exported type. The factory keeps accepting + * widened arrays inline via its overloads, which is what A-4 was about. + */ +describe('wasm-inline WasmEncryptionConfig', () => { + it('rejects an empty schema set on the exported config type', () => { + // @ts-expect-error - at least one table is required + const cfg: WasmEncryptionConfig = { schemas: [], config } + void cfg + }) + + it('still accepts a widened array passed inline to the factory', async () => { + const widened: AnyV3Table[] = [users] + expectTypeOf( + await Encryption({ schemas: widened, config }), + ).toEqualTypeOf<WasmEncryptionClient>() + }) +}) diff --git a/packages/stack/__tests__/wasm-inline-v3.test.ts b/packages/stack/__tests__/wasm-inline-v3.test.ts index 67329827f..917f1f180 100644 --- a/packages/stack/__tests__/wasm-inline-v3.test.ts +++ b/packages/stack/__tests__/wasm-inline-v3.test.ts @@ -1,6 +1,6 @@ /** - * `@cipherstash/stack/wasm-inline` is EQL v3 only (#614). This pins the three - * things that make that true and keep working: + * `@cipherstash/stack/wasm-inline` is EQL v3 only (#614). This pins the things + * that make that true and keep working: * * 1. The factory always constructs the client with `eqlVersion: 3` — a * v2-mode client cannot resolve the concrete `eql_v3_*` domains and would @@ -10,6 +10,12 @@ * WASM client accepts (v3 columns carry `cast_as: 'string'`, not `'text'`). * 3. It rejects a v2 table with a clear message rather than pinning v3 wire to * a v2 schema and failing opaquely inside the FFI. + * 4. That message does not refer the reader on to the native entry for v2 + * authoring, and `config.eqlVersion` is rejected here exactly as the native + * entry rejects it (#815). #815 exists because the two entries disagreed + * about v2; a guard on only one of them would reopen it. + * 5. The runtime `!schemas.length` guard actually runs — it was previously + * pinned only by a `@ts-expect-error` type test, which never executes. * * It also pins that the v3 authoring surface is re-exported from this entry, so * an edge consumer authors v3 schemas from a single import. @@ -32,12 +38,6 @@ vi.mock('@cipherstash/protect-ffi/wasm-inline', () => ({ })) import { newClient as wasmNewClient } from '@cipherstash/protect-ffi/wasm-inline' -// v2 builders come from the native schema entry — used only to prove the WASM -// factory REJECTS a v2 table. -import { - encryptedColumn, - encryptedTable as v2EncryptedTable, -} from '../src/schema' import * as wasm from '../src/wasm-inline' import { Encryption, encryptedTable, types } from '../src/wasm-inline' @@ -75,9 +75,10 @@ describe('wasm-inline is EQL v3 only (#614)', () => { }) it('rejects a v2 table with a clear error, before touching newClient', async () => { - const v2Users = v2EncryptedTable('users', { - email: encryptedColumn('email'), - }) + const v2Users = { + tableName: 'users', + build: () => ({ tableName: 'users', columns: {} }), + } await expect( // A JS caller can bypass the v3-only `schemas` type; the runtime guard // must catch it. Cast to satisfy the compile-time type for this test. @@ -89,6 +90,81 @@ describe('wasm-inline is EQL v3 only (#614)', () => { expect(vi.mocked(wasmNewClient)).not.toHaveBeenCalled() }) + // #815: the message used to close with "(EQL v2 is available on the native + // `@cipherstash/stack` entry.)" — false since the native entry started + // rejecting v2 authoring too. A customer following it hit a second rejection. + // Pin the substantive claim (v2 authoring is gone everywhere, decrypt + // survives), not the `EQL v3 only` prefix that the test above already covers + // and that a stale referral would still satisfy. + it('does not send the reader to the native entry for v2 authoring', async () => { + const v2Users = { + tableName: 'users', + build: () => ({ tableName: 'users', columns: {} }), + } + const err = await Encryption({ + schemas: [v2Users as unknown as typeof users], + config, + }).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(Error) + const message = (err as Error).message + // No referral to another entry as a place v2 authoring still works. + expect(message).not.toMatch(/EQL v2 is available/) + expect(message).not.toMatch(/native `?@cipherstash\/stack`? entry/) + // It must say the removal is repo-wide, and that reads still work — the two + // facts that stop a customer hunting for a v2-capable entry that is gone. + expect(message).toMatch( + /EQL v2 authoring has been removed from every entry/, + ) + expect(message).toMatch(/decrypt/) + }) + + // #815: native throws on the PRESENCE of `config.eqlVersion` + // (`Object.hasOwn`, packages/stack/src/encryption/index.ts). The WASM factory + // had no equivalent, so a JS/JSON caller carrying `eqlVersion: 2` got a hard + // error on one entry and silence on the other — the exact entry-disagreement + // #815 exists to close. + it.each([ + 2, 3, + ])('rejects config.eqlVersion (%i) the way the native entry does', async (eqlVersion) => { + await expect( + Encryption({ + schemas: [users], + // A JS caller can carry this key even though the type omits it. + config: { ...config, eqlVersion } as unknown as typeof config, + }), + ).rejects.toThrow(/`config\.eqlVersion` has been removed/) + expect(vi.mocked(wasmNewClient)).not.toHaveBeenCalled() + }) + + // `eqlVersion?: never` accepts `eqlVersion: undefined` without + // `exactOptionalPropertyTypes` (not enabled in this repo) and cannot be made to + // reject it, so a bare presence check threw on a config the declarations had + // already accepted. Tolerating that one value keeps the type and the runtime in + // agreement — and, like every other rule here, must match the native entry. + it('tolerates an explicitly undefined eqlVersion, matching the native entry', async () => { + await expect( + Encryption({ + schemas: [users], + config: { ...config, eqlVersion: undefined }, + }), + ).resolves.toBeDefined() + expect(newClientOpts().eqlVersion).toBe(3) + }) + + // The runtime `!schemas.length` guard was pinned only by a `@ts-expect-error` + // type test (wasm-inline-schemas.test-d.ts), which never executes — deleting + // the runtime throw left everything green. This executes it. + it('rejects an empty schemas array at runtime, before touching newClient', async () => { + await expect( + Encryption({ + schemas: [] as unknown as [typeof users], + config, + }), + ).rejects.toThrow(/At least one encryptedTable must be provided/) + expect(vi.mocked(wasmNewClient)).not.toHaveBeenCalled() + }) + it('re-exports the v3 authoring surface from this entry', () => { expect(typeof wasm.encryptedTable).toBe('function') expect(typeof wasm.types).toBe('object') diff --git a/packages/stack/dist-types/node16/encrypt-query.cts b/packages/stack/dist-types/node16/encrypt-query.cts index 32b48e7b1..9312726f1 100644 --- a/packages/stack/dist-types/node16/encrypt-query.cts +++ b/packages/stack/dist-types/node16/encrypt-query.cts @@ -15,6 +15,7 @@ */ import type { + AnyV3Table, EncryptedTextSearchColumn, PlaintextForColumn, QueryTypesForColumn, @@ -65,3 +66,41 @@ export async function callable() { queryType: 'searchableJson', }) } + +/** + * The other two halves of the #815 criterion — schema-array shapes and config + * typing — against the CJS declaration set. + * + * `../schemas-and-config.ts` asserts both, but only against the `.d.ts` pass + * under bundler resolution. tsup emits `.d.cts` in a separate DTS pass, so a + * divergence confined to it — in the `Encryption` overloads or in + * `ClientConfig` — would ship green to every CJS consumer. + */ +export async function schemaAndConfigShapes() { + const widened: AnyV3Table[] = [users] + await Encryption({ schemas: widened }) + + const frozen: readonly AnyV3Table[] = [users] + await Encryption({ schemas: frozen }) + + await Encryption({ schemas: [users].map((t) => t) }) + + // @ts-expect-error - at least one table is required + await Encryption({ schemas: [] }) + + await Encryption({ + schemas: [users], + // @ts-expect-error - `eqlVersion` was removed; Stack always authors EQL v3 + config: { eqlVersion: 2 }, + }) + + // Excess-property checking only fires on fresh literals, so a shared config + // const — what a v2 → v3 migration actually holds — used to compile and then + // throw at runtime. + const hoistedConfig = { workspaceCrn: 'crn', eqlVersion: 2 } + await Encryption({ + schemas: [users], + // @ts-expect-error - rejected even when the config is not a fresh literal + config: hoistedConfig, + }) +} diff --git a/packages/stack/dist-types/node16/encrypt-query.mts b/packages/stack/dist-types/node16/encrypt-query.mts index ba8ac246f..d44e7b0ac 100644 --- a/packages/stack/dist-types/node16/encrypt-query.mts +++ b/packages/stack/dist-types/node16/encrypt-query.mts @@ -12,6 +12,7 @@ */ import type { + AnyV3Table, EncryptedTextSearchColumn, PlaintextForColumn, QueryTypesForColumn, @@ -64,3 +65,38 @@ export async function callable() { queryType: 'searchableJson', }) } + +/** + * Same schema-array and config assertions as the `.cts` twin, taken through the + * `import` condition. `../schemas-and-config.ts` covers this declaration set + * already, but by relative path under bundler resolution — it cannot catch a + * shape that resolves for a bundler and not for Node's own algorithm. + */ +export async function schemaAndConfigShapes() { + const widened: AnyV3Table[] = [users] + await Encryption({ schemas: widened }) + + const frozen: readonly AnyV3Table[] = [users] + await Encryption({ schemas: frozen }) + + await Encryption({ schemas: [users].map((t) => t) }) + + // @ts-expect-error - at least one table is required + await Encryption({ schemas: [] }) + + await Encryption({ + schemas: [users], + // @ts-expect-error - `eqlVersion` was removed; Stack always authors EQL v3 + config: { eqlVersion: 2 }, + }) + + // Excess-property checking only fires on fresh literals, so a shared config + // const — what a v2 → v3 migration actually holds — used to compile and then + // throw at runtime. + const hoistedConfig = { workspaceCrn: 'crn', eqlVersion: 2 } + await Encryption({ + schemas: [users], + // @ts-expect-error - rejected even when the config is not a fresh literal + config: hoistedConfig, + }) +} diff --git a/packages/stack/dist-types/node16/wasm-inline.mts b/packages/stack/dist-types/node16/wasm-inline.mts index 947e2b4ca..632f07e19 100644 --- a/packages/stack/dist-types/node16/wasm-inline.mts +++ b/packages/stack/dist-types/node16/wasm-inline.mts @@ -19,10 +19,14 @@ * what collapses if the phantom carrier is lost on emit. */ -import type { - EncryptedTextSearchColumn, - PlaintextForColumn, - QueryTypesForColumn, +import { + type AnyV3Table, + type EncryptedTextSearchColumn, + encryptedTable, + type PlaintextForColumn, + type QueryTypesForColumn, + types, + Encryption as WasmEncryption, } from '@cipherstash/stack/wasm-inline' const plaintext: string = @@ -31,3 +35,69 @@ const queryTypes: 'equality' | 'orderAndRange' | 'freeTextSearch' = null as unknown as QueryTypesForColumn<EncryptedTextSearchColumn> void plaintext void queryTypes + +/** + * The same schema-array shapes the native entry accepts (#815 review). + * + * `WasmEncryptionConfig.schemas` was a MUTABLE non-empty tuple long after A-4 + * widened the native twin, so the two entries disagreed about identical calls: + * a `.map()` result or a `readonly` array compiled against `@cipherstash/stack` + * and failed against `@cipherstash/stack/wasm-inline`, while their runtimes + * agreed exactly (`!schemas.length`). Pinned against the built `.d.ts` because + * this entry gets its own tsup DTS pass — a source-only type test cannot see it. + */ +const wasmUsers = encryptedTable('users', { + email: types.TextSearch('email'), +}) + +const wasmConfig = { + workspaceCrn: 'crn', + accessKey: 'ak', + clientId: 'id', + clientKey: 'key', +} + +export async function wasmSchemaShapes() { + await WasmEncryption({ schemas: [wasmUsers], config: wasmConfig }) + + const widened: AnyV3Table[] = [wasmUsers] + await WasmEncryption({ schemas: widened, config: wasmConfig }) + + const frozen: readonly AnyV3Table[] = [wasmUsers] + await WasmEncryption({ schemas: frozen, config: wasmConfig }) + + await WasmEncryption({ + schemas: [wasmUsers].map((t) => t), + config: wasmConfig, + }) + + // @ts-expect-error - at least one table is required + await WasmEncryption({ schemas: [], config: wasmConfig }) +} + +/** + * `eqlVersion` must be rejected here exactly as it is on the native entry. + * + * This entry throws on the key at runtime (the guard mirrors the native one + * byte for byte), so a config the type accepts and the factory rejects is the + * same static/runtime disagreement #815 is about. A fresh literal was already + * caught by excess-property checking; a HOISTED config — one shared const, + * which is what a v2 → v3 migration actually holds — was not, because + * excess-property checking does not apply to it. `eqlVersion?: never` on the + * shared base of the `WasmClientConfig` intersection covers all three auth + * arms at once. + */ +export async function wasmConfigShapes() { + await WasmEncryption({ + schemas: [wasmUsers], + // @ts-expect-error - `eqlVersion` was removed; this entry always emits v3 + config: { ...wasmConfig, eqlVersion: 2 }, + }) + + const hoistedWasmConfig = { ...wasmConfig, eqlVersion: 2 } + await WasmEncryption({ + schemas: [wasmUsers], + // @ts-expect-error - rejected even when the config is not a fresh literal + config: hoistedWasmConfig, + }) +} diff --git a/packages/stack/dist-types/schemas-and-config.ts b/packages/stack/dist-types/schemas-and-config.ts new file mode 100644 index 000000000..6498bdd4c --- /dev/null +++ b/packages/stack/dist-types/schemas-and-config.ts @@ -0,0 +1,95 @@ +/** + * The `Encryption` schema and config typing must survive declaration emit. + * + * The gate next door (`encrypt-query.ts`) covers `encryptQuery` narrowing only. + * That left the other two halves of the #815 acceptance criterion — "Encryption + * static types agree with runtime for v3 schemas, non-tuple schema arrays, and + * config typing" — asserted nowhere against the BUILT package, so a regression + * in either would ship green. + * + * The shapes below are the ones real callers hold. A-4 widened the factory to + * accept them after a mutable non-empty tuple rejected everything that is not + * an array literal; each is pinned here so the emitted `.d.ts` cannot narrow + * back without CI noticing. + */ + +import { Encryption, encryptedTable, types } from '../dist/encryption/v3.js' +import type { AnyV3Table } from '../dist/eql/v3/index.js' +import type { EncryptionClientConfig } from '../dist/types-public.js' + +const users = encryptedTable('users', { + email: types.TextSearch('email'), +}) +const orders = encryptedTable('orders', { + total: types.IntegerOrd('total'), +}) + +export async function schemaShapes() { + // A literal tuple — the baseline. + await Encryption({ schemas: [users, orders] }) + + // A widened array: a shared `export const all: AnyV3Table[]`. + const widened: AnyV3Table[] = [users, orders] + await Encryption({ schemas: widened }) + + // A readonly array, e.g. what `as const` or a ReadonlyArray field yields. + const frozen: readonly AnyV3Table[] = [users, orders] + await Encryption({ schemas: frozen }) + + // A `.map()` result — no literal-ness at all. + await Encryption({ schemas: [users, orders].map((t) => t) }) + + // A caller generic over its own schemas (integration adapters do this). + async function make<S extends readonly [AnyV3Table, ...AnyV3Table[]]>( + schemas: S, + ) { + return Encryption({ schemas }) + } + await make([users]) +} + +export async function configShapes() { + await Encryption({ + schemas: [users], + config: { workspaceCrn: 'crn', accessKey: 'ak' }, + }) + + await Encryption({ + schemas: [users], + // @ts-expect-error - `eqlVersion` was removed; Stack always authors EQL v3 + config: { eqlVersion: 2 }, + }) + + // The same rejection through a hoisted const. Excess-property checking only + // fires on FRESH object literals, so the shape a v2 → v3 migration most + // plausibly has — one shared config object — type-checked clean and then + // threw at runtime. `eqlVersion?: never` on `ClientConfig` makes the two + // agree; the runtime guard stays for JS/JSON callers who bypass types. + const hoistedConfig = { workspaceCrn: 'crn', eqlVersion: 2 } + await Encryption({ + schemas: [users], + // @ts-expect-error - rejected even when the config is not a fresh literal + config: hoistedConfig, + }) + + // @ts-expect-error - at least one table is required + await Encryption({ schemas: [] }) +} + +/** + * The same non-emptiness through the EXPORTED config type, which is the shape a + * caller actually holds when the config is built once and passed around. The + * inline rejection above only covers a fresh literal: with a default type + * argument of `readonly AnyV3Table[]`, `S['length']` widens to `number`, the + * non-empty conditional stops firing, and an empty set type-checked clean here + * before throwing at `Encryption()`. Asserted against the BUILT declarations + * because that is what ships. + */ +export async function exportedConfigTypeShapes() { + // @ts-expect-error - at least one table is required + const empty: EncryptionClientConfig = { schemas: [] } + void empty + + const populated: EncryptionClientConfig = { schemas: [users, orders] } + await Encryption(populated) +} diff --git a/packages/stack/integration/identity/matrix-identity.integration.test.ts b/packages/stack/integration/identity/matrix-identity.integration.test.ts index 6f4bfa709..259b86bed 100644 --- a/packages/stack/integration/identity/matrix-identity.integration.test.ts +++ b/packages/stack/integration/identity/matrix-identity.integration.test.ts @@ -18,8 +18,8 @@ import { OidcFederationStrategy } from '@cipherstash/auth' import { unwrapResult } from '@cipherstash/test-kit' import { clerkJwtProvider } from '@cipherstash/test-kit/integration-clerk' import { beforeAll, describe, expect, it } from 'vitest' -import type { EncryptionClientFor } from '@/encryption/v3' -import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' +import type { EncryptionClient } from '@/encryption/v3' +import { Encryption, encryptedTable, types } from '@/encryption/v3' const users = encryptedTable('v3_identity_live_users', { email: types.TextEq('email'), @@ -36,7 +36,7 @@ const INFRA_FAULT = /ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up|timed? ?out|network error/i describe('v3 typed client identity-aware operations (live)', () => { - let client: EncryptionClientFor<readonly [typeof users]> + let client: EncryptionClient<readonly [typeof users]> beforeAll(async () => { const crn = process.env.CS_WORKSPACE_CRN @@ -50,7 +50,7 @@ describe('v3 typed client identity-aware operations (live)', () => { if (federation.failure) { throw new Error(`[federation]: ${federation.failure.message}`) } - client = await EncryptionV3({ + client = await Encryption({ schemas: [users], config: { authStrategy: federation.data }, }) @@ -115,10 +115,10 @@ describe('v3 typed client identity-aware operations (live)', () => { // test on a Clerk outage or a rotated `CLERK_MACHINE_TOKEN` — for behaviour it // never exercises. describe('v3 typed client audit metadata (live)', () => { - let client: EncryptionClientFor<readonly [typeof users]> + let client: EncryptionClient<readonly [typeof users]> beforeAll(async () => { - client = await EncryptionV3({ schemas: [users] }) + client = await Encryption({ schemas: [users] }) }, 30000) it('accepts .audit({ metadata }) on the encrypt path and still round-trips', async () => { diff --git a/packages/stack/integration/shared/json-crypto.integration.test.ts b/packages/stack/integration/shared/json-crypto.integration.test.ts index 0a54d708a..646792bd0 100644 --- a/packages/stack/integration/shared/json-crypto.integration.test.ts +++ b/packages/stack/integration/shared/json-crypto.integration.test.ts @@ -7,18 +7,18 @@ */ import { unwrapResult } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' -import type { EncryptionClientFor } from '@/encryption/v3' -import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' +import type { EncryptionClient } from '@/encryption/v3' +import { Encryption, encryptedTable, types } from '@/encryption/v3' const docs = encryptedTable('v3_json_docs', { profile: types.Json('profile'), }) describe('v3 typed client — encrypted JSONB round-trip', () => { - let client: EncryptionClientFor<readonly [typeof docs]> + let client: EncryptionClient<readonly [typeof docs]> beforeAll(async () => { - client = await EncryptionV3({ schemas: [docs] }) + client = await Encryption({ schemas: [docs] }) }, 30000) it('round-trips a JSON document through encrypt/decrypt', async () => { diff --git a/packages/stack/integration/shared/match-bloom.integration.test.ts b/packages/stack/integration/shared/match-bloom.integration.test.ts index 7b2543627..b9c28eb7e 100644 --- a/packages/stack/integration/shared/match-bloom.integration.test.ts +++ b/packages/stack/integration/shared/match-bloom.integration.test.ts @@ -1,6 +1,6 @@ import { expect, it } from 'vitest' -import type { EncryptionClientFor } from '@/encryption/v3' -import { EncryptionV3 } from '@/encryption/v3' +import type { EncryptionClient } from '@/encryption/v3' +import { Encryption } from '@/encryption/v3' import type { AnyV3Table } from '@/eql/v3' import { encryptedTable, types } from '@/eql/v3' @@ -38,7 +38,7 @@ const docs = encryptedTable('match_bloom_probe', { const HAYSTACK = 'ada@example.com' async function bloomOf( - client: EncryptionClientFor<readonly AnyV3Table[]>, + client: EncryptionClient<readonly AnyV3Table[]>, value: string, ) { const result = await client.encrypt(value, { column: docs.bio, table: docs }) @@ -67,7 +67,7 @@ it.each([ ])('bloom(needle) is a subset of bloom(haystack) for a $label', async ({ needle, }) => { - const client = await EncryptionV3({ schemas: [docs] }) + const client = await Encryption({ schemas: [docs] }) const haystack = await bloomOf(client, HAYSTACK) const probe = await bloomOf(client, needle) @@ -83,7 +83,7 @@ it.each([ it('a needle absent from the haystack is NOT a bloom subset', async () => { // Without this, an implementation that blooms every needle to the empty set // would satisfy every assertion above. - const client = await EncryptionV3({ schemas: [docs] }) + const client = await Encryption({ schemas: [docs] }) const haystack = await bloomOf(client, HAYSTACK) const probe = await bloomOf(client, 'qqqzzz') @@ -91,7 +91,7 @@ it('a needle absent from the haystack is NOT a bloom subset', async () => { }, 120_000) it('a needle blooms to a non-empty filter', async () => { - const client = await EncryptionV3({ schemas: [docs] }) + const client = await Encryption({ schemas: [docs] }) expect((await bloomOf(client, 'ada')).size).toBeGreaterThan(0) }, 120_000) @@ -106,7 +106,7 @@ it('a needle blooms to a non-empty filter', async () => { * than passing for some unrelated reason. */ it('the subset assertion detects a single foreign token in the operand', async () => { - const client = await EncryptionV3({ schemas: [docs] }) + const client = await Encryption({ schemas: [docs] }) const haystack = await bloomOf(client, HAYSTACK) const substring = await bloomOf(client, 'a@examp') const foreign = await bloomOf(client, 'qqqzzz') diff --git a/packages/stack/integration/shared/matrix-bulk.integration.test.ts b/packages/stack/integration/shared/matrix-bulk.integration.test.ts index e9123ae54..c65c18fbb 100644 --- a/packages/stack/integration/shared/matrix-bulk.integration.test.ts +++ b/packages/stack/integration/shared/matrix-bulk.integration.test.ts @@ -7,8 +7,8 @@ */ import { unwrapResult } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' -import type { EncryptionClientFor } from '@/encryption/v3' -import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' +import type { EncryptionClient } from '@/encryption/v3' +import { Encryption, encryptedTable, types } from '@/encryption/v3' const people = encryptedTable('v3_bulk_people', { nickname: types.TextEq('nickname'), @@ -16,10 +16,10 @@ const people = encryptedTable('v3_bulk_people', { }) describe('v3 typed client bulk-at-scale (live)', () => { - let client: EncryptionClientFor<readonly [typeof people]> + let client: EncryptionClient<readonly [typeof people]> beforeAll(async () => { - client = await EncryptionV3({ schemas: [people] }) + client = await Encryption({ schemas: [people] }) }, 30000) it('round-trips 100 models through bulkEncryptModels/bulkDecryptModels', async () => { diff --git a/packages/stack/integration/shared/matrix-crypto.integration.test.ts b/packages/stack/integration/shared/matrix-crypto.integration.test.ts index 8358d4c2d..7755d6aa6 100644 --- a/packages/stack/integration/shared/matrix-crypto.integration.test.ts +++ b/packages/stack/integration/shared/matrix-crypto.integration.test.ts @@ -27,8 +27,8 @@ import { } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' import { - type EncryptionClientFor, - EncryptionV3, + Encryption, + type EncryptionClient, encryptedTable, } from '@/encryption/v3' import type { AnyV3Table } from '@/eql/v3' @@ -75,12 +75,12 @@ const errorCases = domains.flatMap(([t, spec]) => ) describe('v3 matrix live round-trip (all domains × samples)', () => { - let client: EncryptionClientFor<readonly AnyV3Table[]> + let client: EncryptionClient<readonly AnyV3Table[]> let encrypted: Array<Record<string, unknown>> let decrypted: Array<Record<string, unknown>> beforeAll(async () => { - client = await EncryptionV3({ schemas: [table] as never }) + client = await Encryption({ schemas: [table] as never }) encrypted = unwrapResult( await client.bulkEncryptModels(modelRows as never, table as never), ) as Array<Record<string, unknown>> diff --git a/packages/stack/integration/shared/matrix-keyset.integration.test.ts b/packages/stack/integration/shared/matrix-keyset.integration.test.ts index d1bba4ff8..3d73aee0e 100644 --- a/packages/stack/integration/shared/matrix-keyset.integration.test.ts +++ b/packages/stack/integration/shared/matrix-keyset.integration.test.ts @@ -7,16 +7,16 @@ import { ensureKeyset } from '@cipherstash/protect-ffi' import { unwrapResult } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' -import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' +import { Encryption, encryptedTable, types } from '@/encryption/v3' const users = encryptedTable('v3_keyset_users', { email: types.TextEq('email'), }) -describe('EncryptionV3 keyset config (deterministic)', () => { +describe('Encryption keyset config (deterministic)', () => { it('rejects an invalid keyset id before touching the network', async () => { await expect( - EncryptionV3({ + Encryption({ schemas: [users], config: { keyset: { id: 'invalid-uuid' } }, }), @@ -26,7 +26,7 @@ describe('EncryptionV3 keyset config (deterministic)', () => { }) }) -describe('EncryptionV3 keyset config (live)', () => { +describe('Encryption keyset config (live)', () => { let keysetId: string beforeAll(async () => { @@ -35,7 +35,7 @@ describe('EncryptionV3 keyset config (live)', () => { }, 30000) it('round-trips a value using an explicit keyset id', async () => { - const client = await EncryptionV3({ + const client = await Encryption({ schemas: [users], config: { keyset: { id: keysetId } }, }) diff --git a/packages/stack/integration/shared/matrix-sql.integration.test.ts b/packages/stack/integration/shared/matrix-sql.integration.test.ts index 2fb113887..a4f49bb78 100644 --- a/packages/stack/integration/shared/matrix-sql.integration.test.ts +++ b/packages/stack/integration/shared/matrix-sql.integration.test.ts @@ -68,8 +68,8 @@ import { import postgres from 'postgres' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { - type EncryptionClientFor, - EncryptionV3, + Encryption, + type EncryptionClient, encryptedTable, } from '@/encryption/v3' import type { AnyV3Table } from '@/eql/v3' @@ -208,7 +208,7 @@ const comparePlaintext = (a: unknown, b: unknown): number => { type Row = { id: number } -let client: EncryptionClientFor<readonly AnyV3Table[]> +let client: EncryptionClient<readonly AnyV3Table[]> let idA: number let idB: number // Separate run id for the multi-row ordering rows. Kept DISTINCT from @@ -232,7 +232,7 @@ const orderOperands: Record<string, unknown[]> = {} beforeAll(async () => { // EQL v3 is installed once per run by `global-setup.ts`. - client = await EncryptionV3({ schemas: [table] as never }) + client = await Encryption({ schemas: [table] as never }) const columnDefs = domains .map(([t]) => `"${slug(t)}" ${t} NOT NULL`) diff --git a/packages/stack/integration/shared/ope-term.integration.test.ts b/packages/stack/integration/shared/ope-term.integration.test.ts index 64c6afa0a..bd018e913 100644 --- a/packages/stack/integration/shared/ope-term.integration.test.ts +++ b/packages/stack/integration/shared/ope-term.integration.test.ts @@ -1,5 +1,5 @@ import { expect, it } from 'vitest' -import { EncryptionV3 } from '@/encryption/v3' +import { Encryption } from '@/encryption/v3' import { encryptedTable, types } from '@/eql/v3' /** @@ -37,7 +37,7 @@ async function opTerms( values: readonly unknown[], column: 'amount' | 'when' | 'name', ) { - const client = await EncryptionV3({ schemas: [table] }) + const client = await Encryption({ schemas: [table] }) const terms: string[] = [] for (const value of values) { const result = await client.encrypt(value as never, { diff --git a/packages/stack/integration/shared/schema-pg.integration.test.ts b/packages/stack/integration/shared/schema-pg.integration.test.ts index 152b94801..fe366be74 100644 --- a/packages/stack/integration/shared/schema-pg.integration.test.ts +++ b/packages/stack/integration/shared/schema-pg.integration.test.ts @@ -1,7 +1,7 @@ import { databaseUrl, unwrapResult } from '@cipherstash/test-kit' import postgres from 'postgres' import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' -import type { EncryptionClientFor } from '@/encryption/v3' +import type { EncryptionClient } from '@/encryption/v3' import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' import type { Encrypted } from '@/types' @@ -28,9 +28,7 @@ type InsertedRow = { type EncryptionPayload = postgres.JSONValue -let protectClient: EncryptionClientFor< - readonly [typeof table, typeof typedTable] -> +let protectClient: EncryptionClient<readonly [typeof table, typeof typedTable]> async function encryptValue(value: string): Promise<EncryptionPayload> { return unwrapResult( @@ -90,13 +88,8 @@ async function seedRows(): Promise<Record<string, number>> { beforeAll(async () => { // EQL v3 is installed once per run by `global-setup.ts`. - // `eqlVersion: 3` is required for v3 concrete-type schemas: protect-ffi's - // newClient defaults to v2, and a v2-mode client cannot encrypt these columns - // (it throws "Cannot convert undefined or null to object"). EncryptionV3 sets - // this automatically; the base `Encryption` factory does not, so pass it here. protectClient = await Encryption({ schemas: [table, typedTable], - config: { eqlVersion: 3 }, }) // DROP first: these tables are created with IF NOT EXISTS and cleaned up by diff --git a/packages/stack/integration/shared/schema-v3-client.integration.test.ts b/packages/stack/integration/shared/schema-v3-client.integration.test.ts index 80e60e647..e2eb9e46c 100644 --- a/packages/stack/integration/shared/schema-v3-client.integration.test.ts +++ b/packages/stack/integration/shared/schema-v3-client.integration.test.ts @@ -1,6 +1,6 @@ import { unwrapResult } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' -import type { EncryptionClientFor } from '@/encryption/v3' +import type { EncryptionClient } from '@/encryption/v3' import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' @@ -19,7 +19,7 @@ const users = encryptedTable('schema_v3_client_users', { }) describe('eql_v3 client integration', () => { - let protectClient: EncryptionClientFor<readonly [typeof users]> + let protectClient: EncryptionClient<readonly [typeof users]> beforeAll(async () => { protectClient = await Encryption({ schemas: [users] }) diff --git a/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts b/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts index 7926837a7..505e94814 100644 --- a/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts +++ b/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts @@ -1,213 +1,560 @@ /** - * Acceptance #1a + #1b + #1c — EQL v2 READ compatibility after the v3 collapse. + * Native v2 read compatibility after removal of the public v2 authoring path. * - * The client authors EQL v3 only, but MUST keep reading previously stored EQL v2 - * payloads — on the core client (guardrail 1) AND through the DynamoDB adapter - * (guardrail 2). This suite mints v2 data with a v2-mode `Encryption` client (the - * retained `config: { eqlVersion: 2 }` escape hatch) and proves it still - * round-trips: + * Fixtures are minted directly with protect-ffi in EQL v2 mode. This is + * deliberately integration-only: production callers cannot select v2 writes, + * while the native v3 client must continue to decrypt data written before the + * upgrade. * - * - #1a: a v2 ciphertext + a v2 model decrypt through the collapsed `Encryption` - * client's `decrypt` / `decryptModel`. - * - #1b: a stored v2 DynamoDB item — split with the exported - * `toEncryptedDynamoItem(payload, attrs, false)` — decrypts through - * `encryptedDynamoDB(...).decryptModel(item, v2Table)`, exercising the v2 - * envelope reconstruction (`toItemWithEqlPayloads`, `v === 2` / `k: 'ct'`). - * - #1c: the SAME v2 payloads decrypt through a **v3-configured** client that - * has never heard of the v2 table. + * Three blocks. The first two are about SURFACES (which read path), the third is + * about TYPES (which plaintext axis), and the SECOND is the one that carries the + * promise: * - * #1c is the invariant customers actually depend on and the reason this file - * cannot be a v2-only suite: after the removal their client is v3, their stored - * data is v2, and #1a/#1b would keep passing even if a v3 client had lost the - * ability to read v2 — because both mint AND read through the v2-mode client. - * Whatever eventually deletes the `eqlVersion: 2` minting path must keep #1c - * alive (against a checked-in ciphertext fixture, or a raw `EncryptConfig`), - * not delete it along with the escape hatch. + * - `native v3 client reads stored EQL v2 payloads` — a v3 client reads v2 data + * for a table it still registers. The everyday case, but a weak detector: the + * reading client is configured for exactly the table the fixtures name, so it + * would keep passing even if decrypt started resolving payloads through the + * encrypt config. + * - `a client that never registered the v2 table still reads its payloads` — + * the same fixtures, read through a client configured ONLY for an unrelated + * table. This is what a real customer is left with: they migrated, their + * schema is whatever they author today, and their database still holds v2 + * rows for columns their current schema may no longer mention at all. + * - `a v3 client reconstructs every plaintext axis…` — the same read, across + * the domain catalog rather than a single `TextEq` column. See that block's + * own header for why the type axis needs separate proof from the surface axis. * - * Live: requires real ZeroKMS credentials (the integration harness provisions - * them and throws otherwise). The credential-free half of the v2 read path — the - * DynamoDB envelope split/reconstruction over static payloads — is covered by - * `__tests__/dynamodb/helpers.test.ts`, which needs no network at all. + * The unrelated table is the entire point of the second block, and it is not + * obvious on sight: it forces the reads to prove that decrypt is + * PAYLOAD-SHAPE-DRIVEN and never consults the encrypt config. Nothing looks up + * `i.t` / `i.c` — `isEncryptedPayload` selects fields structurally + * (`src/encryption/helpers/index.ts`, `helpers/model-traversal.ts`) and + * protect-ffi's `decrypt` accepts either wire generation regardless of the + * client's own `eqlVersion`. Delete the unrelated table, or point these reads + * back at `users`, and the block silently stops testing anything. + * + * The invariant is scoped to the client's TABLE-LESS reads. The DynamoDB + * adapter always forwards a table, and the table-aware overload does consult + * the schema tuple — so legacy DynamoDB reads require the table to be declared + * in `Encryption({ schemas })`. The last case in the second block pins that + * boundary so the difference is not mistaken for a regression later. + * + * Whatever next removes code here must keep that second block alive. It is the + * successor to the `#1c` case that guarded this invariant while the + * `config: { eqlVersion: 2 }` escape hatch still existed; the hatch is gone + * (`Encryption()` now rejects the field outright), the invariant is not. */ -import { unwrapResult } from '@cipherstash/test-kit' +import type { JsPlaintext } from '@cipherstash/protect-ffi' +import { + encrypt as ffiEncrypt, + encryptBulk as ffiEncryptBulk, + newClient as newFfiClient, +} from '@cipherstash/protect-ffi' +import { + unwrapResult, + V3_MATRIX, + v2FixtureColumns, + v2FixturePlan, + v2ModelRows, + v2OpeIndexedDomains, + v2UndeclaredCastAs, +} from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' import { encryptedDynamoDB } from '@/dynamodb' import { toEncryptedDynamoItem } from '@/dynamodb/helpers' -import type { EncryptionClient } from '@/encryption' -import { encryptedTable as encryptedTableV3, types as typesV3 } from '@/eql/v3' +import { buildEncryptConfig, encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' -// The deprecated v2 authoring builders remain for reading/migrating legacy data. -import { encryptedColumn, encryptedTable } from '@/schema' +import type { CastAs } from '@/schema' +import type { Encrypted } from '@/types' -// A minimal EQL v2 table (v2 builders → no `buildColumnKeyMap`, so `Encryption` -// returns the nominal client, not the typed one). -const usersV2 = encryptedTable('v2_read_compat_users', { - email: encryptedColumn('email').equality(), +const users = encryptedTable('v2_read_compat_users', { + email: types.TextEq('email'), + altEmail: types.TextEq('alt_email'), }) -// A DIFFERENT table, in v3 builders, so the v3 client below carries no knowledge -// of the v2 table whatsoever — reading v2 data must not depend on the v2 schema -// still being registered. -const unrelatedV3 = encryptedTableV3('v2_read_compat_unrelated_v3', { - note: typesV3.TextEq('note'), +/** + * A DIFFERENT table, sharing no name or column with `users`. `unrelatedClient` + * below is built on this one alone, so its encrypt config has never heard of + * `v2_read_compat_users` — which is what makes the reads in the second describe + * block load-bearing rather than incidental. + */ +const unrelated = encryptedTable('v2_read_compat_unrelated_v3', { + note: types.TextEq('note'), }) const SECRET = 'ada@example.com' +let fixtureClient: Awaited<ReturnType<typeof newFfiClient>> +let client: Awaited<ReturnType<typeof makeClient>> +let unrelatedClient: Awaited<ReturnType<typeof makeUnrelatedClient>> -// A v2-mode client: the explicit `eqlVersion: 2` escape hatch forces v2 wire so -// this suite mints genuinely-v2 payloads, independent of schema auto-detection. -let v2Client: EncryptionClient - -// The client a customer is left with after the removal: v3 schemas, default -// (v3) wire version. Every read in #1c goes through this one. -// -// Typed through a thunk rather than annotated `EncryptionClient`: a concrete v3 -// schema set selects the TYPED overload, and `TypedEncryptionClient` is not -// assignable to the nominal `EncryptionClient`. -const makeV3Client = () => Encryption({ schemas: [unrelatedV3] }) -let v3Client: Awaited<ReturnType<typeof makeV3Client>> +const makeClient = () => Encryption({ schemas: [users] }) +// Typed through a thunk for the same reason as `makeClient`: a concrete schema +// tuple selects the typed overload, whose result is not the nominal client type. +const makeUnrelatedClient = () => Encryption({ schemas: [unrelated] }) beforeAll(async () => { - v2Client = await Encryption({ - schemas: [usersV2], - config: { eqlVersion: 2 }, + fixtureClient = await newFfiClient({ + encryptConfig: buildEncryptConfig(users), + eqlVersion: 2, }) - v3Client = await Encryption({ schemas: [unrelatedV3] }) + client = await makeClient() + unrelatedClient = await makeUnrelatedClient() }) -describe('#1a — core client reads a stored EQL v2 payload', () => { - it('round-trips a v2 ciphertext through decrypt', async () => { - const encrypted = unwrapResult( - await v2Client.encrypt(SECRET, { table: usersV2, column: usersV2.email }), - ) - // Guard against a false pass: it must be an actual ciphertext. - expect(encrypted).toHaveProperty('c') +async function v2Ciphertext(value: string): Promise<Encrypted> { + return (await ffiEncrypt(fixtureClient, { + plaintext: value, + table: users.tableName, + column: users.email.getName(), + })) as Encrypted +} - const decrypted = unwrapResult(await v2Client.decrypt(encrypted)) - expect(decrypted).toBe(SECRET) +describe('native v3 client reads stored EQL v2 payloads', () => { + it('decrypts a scalar ciphertext', async () => { + const encrypted = await v2Ciphertext(SECRET) + expect(encrypted).toMatchObject({ v: 2 }) + + expect(unwrapResult(await client.decrypt(encrypted))).toBe(SECRET) }, 30000) - it('round-trips a v2 model through decryptModel', async () => { - const encryptedModel = unwrapResult( - await v2Client.encryptModel({ pk: 'a', email: SECRET }, usersV2), - ) - expect(encryptedModel.email).toHaveProperty('c') + it('decrypts a model without registering a legacy schema', async () => { + const encrypted = await v2Ciphertext(SECRET) - const decrypted = unwrapResult(await v2Client.decryptModel(encryptedModel)) - expect(decrypted).toEqual({ pk: 'a', email: SECRET }) + expect( + unwrapResult(await client.decryptModel({ pk: 'a', email: encrypted })), + ).toEqual({ pk: 'a', email: SECRET }) }, 30000) -}) -describe('#1b — DynamoDB adapter reads a stored EQL v2 item', () => { - it('decrypts a v2-split DynamoDB item via encryptedDynamoDB.decryptModel', async () => { - // Stage a stored v2 item: encrypt a v2 model, then split it into DynamoDB - // attributes exactly as the (removed-at-the-type-level, retained-at-runtime) - // v2 write path would have — `toEncryptedDynamoItem(..., false)`. - const encryptedModel = unwrapResult( - await v2Client.encryptModel({ pk: 'a', email: SECRET }, usersV2), + /** + * The state a real migration actually leaves behind: rows written before the + * upgrade sit alongside rows written after, and a single model carries both. + * Every other case here is all-v2, which is only ever true immediately before + * the first v3 write. + */ + it('decrypts a model mixing v2 and v3 fields', async () => { + const legacy = await v2Ciphertext(SECRET) + const current = unwrapResult( + await client.encrypt('grace@example.com', { + table: users, + column: users.altEmail, + }), ) - const storedV2Item = toEncryptedDynamoItem(encryptedModel, ['email'], false) + expect(legacy).toMatchObject({ v: 2 }) + expect(current).toMatchObject({ v: 3 }) - // The email column becomes `email__source` (+ `email__hmac` for equality); - // the plaintext key does not survive the split. - expect(storedV2Item).toHaveProperty('email__source') - expect(storedV2Item).not.toHaveProperty('email') - expect(storedV2Item.pk).toBe('a') + expect( + unwrapResult( + await client.decryptModel( + { pk: 'a', email: legacy, altEmail: current }, + users, + ), + ), + ).toEqual({ pk: 'a', email: SECRET, altEmail: 'grace@example.com' }) + }, 30000) + + it('bulk-decrypts v2 ciphertexts', async () => { + // protect-ffi's `EncryptPayload` is `{ plaintext, column, table, lockContext? }` + // — it carries no `id`, and correlates results positionally. The `id`s below + // belong to `bulkDecrypt`, which is this package's own API and does take them. + const encrypted = (await ffiEncryptBulk(fixtureClient, { + plaintexts: [ + { plaintext: SECRET, table: users.tableName, column: 'email' }, + { + plaintext: 'grace@example.com', + table: users.tableName, + column: 'email', + }, + ], + })) as Encrypted[] - const dynamo = encryptedDynamoDB({ encryptionClient: v2Client }) const decrypted = unwrapResult( - await dynamo.decryptModel(storedV2Item, usersV2), + await client.bulkDecrypt([ + { id: '1', data: encrypted[0] }, + { id: '2', data: encrypted[1] }, + ]), ) + expect(decrypted).toEqual([ + { id: '1', data: SECRET }, + { id: '2', data: 'grace@example.com' }, + ]) + }, 30000) + + it('decrypts a DynamoDB item reconstructed as stored EQL v2', async () => { + const encrypted = await v2Ciphertext(SECRET) + const stored = toEncryptedDynamoItem({ pk: 'a', email: encrypted }, [ + 'email', + ]) + const dynamo = encryptedDynamoDB({ encryptionClient: client }) + const decrypted = unwrapResult( + await dynamo.decryptModel(stored, users, { storedEqlVersion: 2 }), + ) expect(decrypted).toMatchObject({ pk: 'a', email: SECRET }) }, 30000) }) -// The real-world shape of the compatibility promise: the customer upgraded, so -// their client is v3-configured and knows nothing about the v2 table — but the -// rows already in their database are v2. -describe('#1c — a v3-configured client reads stored EQL v2 payloads', () => { - // The precondition every other case in this block rests on. Without it the - // whole describe can pass vacuously: if v3 detection regressed, - // `resolveEqlVersion` would return `undefined` rather than throw, leaving - // `v3Client` on the FFI's v2 default — and a v2-mode client reads v2 payloads - // natively, so every assertion below would still be green while the thing - // under test (a *v3* client reading v2) was never exercised. `v3Client` is - // typed through a thunk, so the overload collapse wouldn't surface as a type - // error either. The credential-free half of this guard — the detection matrix - // itself — is `__tests__/resolve-eql-version.test.ts`. - it('is genuinely in EQL v3 mode, or the cases below prove nothing', async () => { - const v3Payload = unwrapResult( - await v3Client.encrypt('a v3-authored note', { - table: unrelatedV3, - column: unrelatedV3.note, - }), - ) +/** + * The invariant customers actually depend on: decrypt is driven by the payload, + * never by the encrypt config. Every read below goes through `unrelatedClient`, + * which is configured for `v2_read_compat_unrelated_v3` and nothing else, while + * the fixtures carry `i.t = 'v2_read_compat_users'`. A customer whose schema + * dropped or renamed a column — or who never re-declared it after migrating to + * v3 — must still be able to read the v2 rows already on disk. + * + * Keep this block reading through `unrelatedClient`. Swapping it for `client` + * would leave every assertion green while testing nothing the block above does + * not already cover. + */ +describe('a client that never registered the v2 table still reads its payloads', () => { + // Precondition for everything below. If `unrelatedClient` ever ends up + // registering `users` — a stray schema added here, a factory that merges + // configs — the cases become indistinguishable from the first block and pass + // vacuously. Assert the absence directly rather than trusting the setup. + it('is configured for the unrelated table alone, or the cases below prove nothing', () => { + const tables = unrelatedClient.getEncryptConfig()?.tables + expect(Object.keys(tables ?? {})).toEqual([unrelated.tableName]) + expect(tables).not.toHaveProperty(users.tableName) + }) - expect(v3Payload).toMatchObject({ - v: 3, - i: { t: 'v2_read_compat_unrelated_v3', c: 'note' }, + it('decrypts a scalar ciphertext for an unregistered table', async () => { + const encrypted = await v2Ciphertext(SECRET) + // Guard against a false pass: this must be genuinely v2 wire, and it must + // name the table the reading client does not have. + expect(encrypted).toMatchObject({ + v: 2, + i: { t: users.tableName, c: 'email' }, }) - // The structural discriminator, and the stronger half of this guard: a v2 - // scalar carries `k: 'ct'`, a v3 scalar carries no `k` at all (see the - // narrowing note in `src/types.ts`). This still catches a regression that - // somehow preserved `v`. - expect(v3Payload).not.toHaveProperty('k') - }, 30000) - it('decrypts a v2 ciphertext minted before the upgrade', async () => { - const encrypted = unwrapResult( - await v2Client.encrypt(SECRET, { table: usersV2, column: usersV2.email }), - ) - // Guard against a false pass: this must be a genuine v2 payload, or the - // test proves nothing about v2 compatibility. - expect(encrypted).toMatchObject({ v: 2 }) - - const decrypted = unwrapResult(await v3Client.decrypt(encrypted)) - expect(decrypted).toBe(SECRET) + expect(unwrapResult(await unrelatedClient.decrypt(encrypted))).toBe(SECRET) }, 30000) - it('decrypts a v2 model minted before the upgrade', async () => { - const encryptedModel = unwrapResult( - await v2Client.encryptModel({ pk: 'a', email: SECRET }, usersV2), - ) - expect(encryptedModel.email).toMatchObject({ v: 2 }) + it('decrypts a model whose encrypted field belongs to an unregistered table', async () => { + const encrypted = await v2Ciphertext(SECRET) - const decrypted = unwrapResult(await v3Client.decryptModel(encryptedModel)) - expect(decrypted).toEqual({ pk: 'a', email: SECRET }) + // No table argument: field selection is structural (`isEncryptedPayload`), + // so there is nothing for the client to look the column up in. + expect( + unwrapResult( + await unrelatedClient.decryptModel({ pk: 'a', email: encrypted }), + ), + ).toEqual({ pk: 'a', email: SECRET }) }, 30000) - it('bulk-decrypts v2 ciphertexts minted before the upgrade', async () => { - const encrypted = unwrapResult( - await v2Client.bulkEncrypt( - [ - { id: '1', plaintext: SECRET }, - { id: '2', plaintext: 'grace@example.com' }, - ], - { table: usersV2, column: usersV2.email }, - ), - ) + it('bulk-decrypts ciphertexts for an unregistered table', async () => { + // Minted one at a time rather than through `ffiEncryptBulk`: the bulk path + // is on the READ side here, and the fixtures only need to be v2. (The + // block above already covers the bulk mint.) + const [first, second] = await Promise.all([ + v2Ciphertext(SECRET), + v2Ciphertext('grace@example.com'), + ]) - const decrypted = unwrapResult(await v3Client.bulkDecrypt(encrypted)) + const decrypted = unwrapResult( + await unrelatedClient.bulkDecrypt([ + { id: '1', data: first }, + { id: '2', data: second }, + ]), + ) expect(decrypted).toEqual([ { id: '1', data: SECRET }, { id: '2', data: 'grace@example.com' }, ]) }, 30000) - it('decrypts a stored v2 DynamoDB item through a v3-configured adapter', async () => { - const encryptedModel = unwrapResult( - await v2Client.encryptModel({ pk: 'a', email: SECRET }, usersV2), + /** + * The DynamoDB adapter does NOT extend the guarantee above, and this case + * pins that boundary rather than asserting it away. + * + * There are two independent registration checks on this path, and clearing + * the first does not clear the second. `assertClientTableVersionMatch` + * (`src/dynamodb/index.ts`) early-returns for `storedEqlVersion: 2`, because + * a v2 payload says nothing about which v3 tables the client holds. But the + * adapter then forwards the table into `client.decryptModel(item, table)` — + * deliberately, to preserve Date reconstruction — and the TABLE-AWARE + * overload rejects a table outside the schema tuple + * (`src/encryption/client-v3.ts`). The native reads above survive because + * they use the table-LESS overload, which has no such map to miss. + * + * The pre-#815 version of this case passed a v2 table descriptor, which side- + * stepped the v3 guard entirely. That shape is now unreproducible: the v2 + * builders are gone, so the only descriptor that exists is a v3 one. Reading + * legacy DynamoDB data therefore requires declaring the table in + * `Encryption({ schemas })` — a real constraint, not a regression, and one + * the caller must already satisfy to have a descriptor to pass. + */ + it('refuses a stored v2 DynamoDB item whose table the client never registered', async () => { + const encrypted = await v2Ciphertext(SECRET) + const stored = toEncryptedDynamoItem({ pk: 'a', email: encrypted }, [ + 'email', + ]) + const dynamo = encryptedDynamoDB({ encryptionClient: unrelatedClient }) + + const result = await dynamo.decryptModel(stored, users, { + storedEqlVersion: 2, + }) + + expect(result.failure?.message).toMatch(/was not initialized with/) + }, 30000) +}) + +/** + * The TYPE axis of the same obligation. Everything above reads v2 payloads from + * a single `types.TextEq` column, so it proves the SURFACES (scalar / model / + * bulk / mixed / DynamoDB / unregistered table) and nothing about what happens + * to a `date`, a `bigint`, or a `boolean` on the way back. + * + * That gap is not cosmetic, because reconstruction is driven by the v3 table's + * `cast_as` and applied REGARDLESS of the payload's wire version: + * + * - `date` / `timestamp` — `DATE_LIKE_CASTS` (`src/eql/v3/columns.ts`) selects + * the date properties, `rowReconstructor` (`src/encryption/client-v3.ts`) + * rebuilds them via `reconstructDatePaths`. A v2 payload read through a v3 + * descriptor gets that treatment; nothing here has ever checked the result. + * - `bigint` — the sharpest case. `PlaintextFromKind` promises `bigint` and the + * table-aware overload returns `V3DecryptedModel<Table, T>`, so the TYPE says + * `bigint` even on a legacy read. Native `bigint` on decrypt is a property of + * the v3 path; whether a v2 payload yields the same is exactly what was + * unverified. Asserted with an explicit `typeof`, not a loose equality — if + * v2 hands back a string, that is a product defect and this must say so. + * + * Structure mirrors `matrix-crypto.integration.test.ts`, which does the same for + * v3 writes: driven from `V3_MATRIX` (compile-time exhaustive, so a new domain + * cannot be forgotten), one mega table spanning every mintable domain, and the + * whole matrix through ONE `encryptBulk` + ONE `bulkDecryptModels` rather than + * ~100 sequential calls. + * + * Reads go through the MODEL path deliberately: that is where reconstruction + * happens. The single-value `decrypt` of a date-like column returns the stored + * string by design, and the last case pins that boundary rather than hiding it. + * + * Domain SELECTION lives in `@cipherstash/test-kit`'s `v2-fixtures`, not here: + * the WASM and DynamoDB surfaces need the same set, and three files quietly + * disagreeing about which domains they cover is the failure this whole file + * exists to prevent. Exclusions are declared there with a written reason, and + * the first three cases below are the accounting that keeps them honest. + * + * That selection deliberately ignores `DomainSpec.deferred`, which is about a + * Postgres operator class being superuser-only. No table is created here and no + * row is stored — this is pure crypto, exactly like `matrix-crypto`, which + * likewise covers the block-ORE domains. Mixing the two exclusion axes would + * drop domains for a reason that does not apply. + */ +const matrixPlan = v2FixturePlan() +const matrix = encryptedTable( + 'v2_read_compat_matrix', + v2FixtureColumns(matrixPlan), +) + +// Typed through a thunk for the same reason as `makeClient` above. +const makeMatrixClient = () => Encryption({ schemas: [matrix] }) + +// Flattened to one assertion per (domain, sample), labelled so vitest names the +// exact domain and sample index that failed. +const matrixCases = matrixPlan.cases.map( + (fixture) => + [ + fixture.label, + fixture.slug, + fixture.castAs, + fixture.sample, + fixture.row, + ] as const, +) + +/** + * Assert a decrypted field against its catalog sample, keyed by the plaintext + * axis rather than by `typeof sample`, so the runtime SHAPE is pinned and not + * merely the value. + * + * The `default` arm throws instead of falling back to a loose comparison: a + * catalog domain on a new `cast_as` must arrive with a deliberate assertion, not + * inherit a weak one and look covered. + */ +function expectReconstructed( + actual: unknown, + castAs: CastAs, + sample: unknown, + label: string, +): void { + switch (castAs) { + case 'date': + case 'timestamp': + // `reconstructDatePaths` must have run off the v3 table's cast_as even + // though the payload on the wire was v2. + expect(actual, `${label}: expected a reconstructed Date`).toBeInstanceOf( + Date, + ) + expect(actual).toEqual(sample) + return + case 'bigint': + // Not `toEqual`: a string "42" and 42n compare unequal under toBe, but the + // point is the TYPE, and only an explicit typeof states it. + expect(typeof actual, `${label}: expected a native bigint`).toBe('bigint') + expect(actual).toBe(sample) + return + case 'number': + expect(typeof actual, `${label}: expected a number`).toBe('number') + expect(actual).toBe(sample) + return + case 'string': + expect(typeof actual, `${label}: expected a string`).toBe('string') + expect(actual).toBe(sample) + return + case 'boolean': + expect(typeof actual, `${label}: expected a boolean`).toBe('boolean') + expect(actual).toBe(sample) + return + default: + throw new Error( + `${label}: no reconstruction assertion for cast_as "${castAs}". ` + + 'Add one rather than letting a new plaintext axis pass unchecked.', + ) + } +} + +describe('a v3 client reconstructs every plaintext axis from stored EQL v2 payloads', () => { + let matrixFixtureClient: Awaited<ReturnType<typeof newFfiClient>> + let matrixClient: Awaited<ReturnType<typeof makeMatrixClient>> + let encrypted: Array<Record<string, Encrypted>> + let decrypted: Array<Record<string, unknown>> + let singleRow: Record<string, unknown> + + beforeAll(async () => { + matrixFixtureClient = await newFfiClient({ + encryptConfig: buildEncryptConfig(matrix), + eqlVersion: 2, + }) + matrixClient = await makeMatrixClient() + + // One network call for the whole matrix. `encryptBulk` correlates results + // POSITIONALLY (its payload type carries no id), which is why every case + // below re-checks the identifier it got back. + const payloads = (await ffiEncryptBulk(matrixFixtureClient, { + plaintexts: matrixPlan.cases.map((fixture) => ({ + plaintext: fixture.plaintext as JsPlaintext, + table: matrix.tableName, + column: fixture.slug, + })), + })) as Encrypted[] + + encrypted = v2ModelRows(matrixPlan, payloads) + decrypted = unwrapResult( + await matrixClient.bulkDecryptModels(encrypted, matrix), ) - const storedV2Item = toEncryptedDynamoItem(encryptedModel, ['email'], false) + singleRow = unwrapResult( + await matrixClient.decryptModel(encrypted[0], matrix), + ) + }, 60000) - // The adapter is built on the v3 client; only the TABLE argument still - // describes the legacy v2 shape, because that is what the item on disk is. - const dynamo = encryptedDynamoDB({ encryptionClient: v3Client }) - const decrypted = unwrapResult( - await dynamo.decryptModel(storedV2Item, usersV2), + it('accounts for every catalog domain as either minted or deferred', () => { + const accounted = [ + ...matrixPlan.domains.map((domain) => domain.eqlType), + ...matrixPlan.deferred.map((domain) => domain.eqlType), + ].sort() + // The partition is the coverage mechanism: a domain that is in neither set + // has been dropped, and one in both would be counted twice. + expect(accounted).toEqual(Object.keys(V3_MATRIX).sort()) + expect(matrixPlan.domains.length).toBeGreaterThan(0) + for (const { eqlType, reason } of matrixPlan.deferred) { + expect(reason, `${eqlType} is deferred with no reason`).not.toBe('') + } + }) + + /** + * The exclusions are a hand-written list so that a NEW domain defaults to + * covered and fails loudly. This is what stops that list drifting away from + * the rule it claims to encode: the deferred set must be exactly the + * `ope`-indexed domains (no such v2 payload can exist — EQL v2 scalars carry + * `hm`/`bf`/`ob`, never `op`) plus the one `ste_vec` domain the shipped client + * refuses to write in v2 mode. Add an ope-indexed domain and this goes red + * with a diff naming it, rather than silently skipping it. + */ + it('defers exactly the domains its written reasons cover', () => { + expect(matrixPlan.deferred.map((domain) => domain.eqlType).sort()).toEqual( + [...v2OpeIndexedDomains(), 'public.eql_v3_json_search'].sort(), ) + }) - expect(decrypted).toMatchObject({ pk: 'a', email: SECRET }) + /** + * Deferring a domain is normally free, because decrypt reconstructs from + * `cast_as` and every deferred domain shares its `cast_as` with a covered one. + * Deferring the LAST domain on an axis is not free, and would otherwise be + * invisible — the suite would still show ~100 green cases. + */ + it('loses no plaintext axis to a deferral without declaring it', () => { + expect(v2UndeclaredCastAs(matrixPlan)).toEqual([]) + // Pinned, not derived: this is the list of axes the v2 READ path is actually + // proven on. `json` is absent and declared unreachable — no v2 ste_vec + // fixture can be minted with cipherstash-client 0.42. + expect( + [...new Set(matrixPlan.cases.map((fixture) => fixture.castAs))].sort(), + ).toEqual(['bigint', 'boolean', 'date', 'number', 'string', 'timestamp']) + }) + + it.each( + matrixCases, + )('%s decrypts from a stored EQL v2 payload through the model path', (label, slug, castAs, sample, row) => { + const payload = encrypted[row][slug] + + // THE guard for this file. Without it the whole matrix would pass just as + // happily against v3 fixtures and prove nothing about legacy reads. The + // identifier is checked too: `encryptBulk` correlates positionally, so a + // mis-zipped batch could otherwise assert one domain's sample against + // another domain's ciphertext and go green by coincidence. + expect(payload).toMatchObject({ + v: 2, + i: { t: matrix.tableName, c: slug }, + }) + expect(payload).toHaveProperty('c') + + expectReconstructed(decrypted[row][slug], castAs, sample, label) + }) + + /** + * `decryptModel` and `bulkDecryptModels` are separate wrappers over the same + * reconstructor, and only the bulk one is exercised above. A row's worth of + * domains through the single path is enough to catch the wrapper being wired + * up without its `map`. + */ + it('reconstructs the same values on the single-model decrypt path', () => { + const firstRow = matrixPlan.cases.filter((fixture) => fixture.row === 0) + expect(firstRow.length).toBe(matrixPlan.domains.length) + for (const fixture of firstRow) { + expectReconstructed( + singleRow[fixture.slug], + fixture.castAs, + fixture.sample, + `${fixture.label} (single model)`, + ) + } + }) + + /** + * The boundary, pinned rather than asserted away: date reconstruction is a + * property of the MODEL path, which has a table to read `cast_as` from. A + * single-value `decrypt` has no table, so it returns the stored string — the + * same contract v3 has, and the reason the matrix above reads through models. + * + * Compared as an instant rather than a literal string: the wire form of a + * `date` plaintext is the FFI's business, and pinning it here would fail on a + * cosmetic change while saying nothing about the contract. + */ + it('returns a date-like scalar as its stored string, not a Date', async () => { + const dateCase = matrixPlan.cases.find( + (fixture) => fixture.castAs === 'date', + ) + if (!dateCase) { + throw new Error( + 'the catalog no longer has a mintable `date` domain to pin this boundary with', + ) + } + + const payload = encrypted[dateCase.row][dateCase.slug] + expect(payload).toMatchObject({ v: 2 }) + + const value = unwrapResult(await matrixClient.decrypt(payload)) + expect(value).not.toBeInstanceOf(Date) + expect(typeof value).toBe('string') + expect(new Date(String(value))).toEqual(dateCase.sample) }, 30000) }) diff --git a/packages/stack/integration/wasm/v2-decrypt-compat.integration.test.ts b/packages/stack/integration/wasm/v2-decrypt-compat.integration.test.ts new file mode 100644 index 000000000..757f0cd51 --- /dev/null +++ b/packages/stack/integration/wasm/v2-decrypt-compat.integration.test.ts @@ -0,0 +1,431 @@ +/** + * WASM v2 read compatibility (#815 review). + * + * The native entry's equivalent lives in `integration/shared/`. This is the + * edge/serverless half of the same obligation: a customer on Deno, Bun, + * Cloudflare Workers or Supabase Edge Functions with rows written before the v3 + * migration must still be able to read them. + * + * The pairing used to be refused outright by `encryptedDynamoDB` on the belief + * that the WASM entry could not serve a legacy read. It can: both bindings are + * builds of the same protect-ffi crate, whose `decrypt` accepts either wire + * generation regardless of the client's `eqlVersion`. This suite is the + * executable proof of that claim — the refusal was lifted on its strength. + * + * Fixtures are minted directly with protect-ffi in EQL v2 mode, exactly as the + * shared suite does: production callers cannot select v2 writes on either entry. + * + * The shared suite's second block — v2 payloads read through a client that never + * registered their table — is mirrored here by ONE case, deliberately narrow. + * The invariant it protects (decrypt is payload-shape-driven and never consults + * the encrypt config) is enforced in two places, and only one of them is shared + * between the entries: the field-selection helpers are the same TypeScript on + * both, but `decrypt` itself is a separate compiled artifact per binding. So the + * native suite proving the native binding ignores its `encryptConfig` says + * nothing about the WASM one, and that gap is what the case below closes. + * + * The rest of the shared block is not duplicated on purpose. Its DynamoDB case + * exercises the `storedEqlVersion: 2` early return in + * `assertClientTableVersionMatch`, which is entry-agnostic TypeScript already + * covered there; re-running it against the WASM client would re-test the same + * branch rather than the WASM binding. Nor is there a `getEncryptConfig()` + * precondition here — this entry's client does not expose one, and the client is + * constructed from `schemas: [unrelated]` a few lines below with nothing merging + * into it. + * + * The second describe block below is about TYPES rather than surfaces, and it is + * NOT a transcription of the shared suite's matrix — see its own header for why + * per-type reconstruction has to be proven separately on this entry. + */ +import type { JsPlaintext } from '@cipherstash/protect-ffi' +import { + encrypt as ffiEncrypt, + encryptBulk as ffiEncryptBulk, + newClient as newFfiClient, +} from '@cipherstash/protect-ffi' +import { + unwrapResult, + v2FixtureColumns, + v2FixturePlan, + v2ModelRows, +} from '@cipherstash/test-kit' +import { beforeAll, describe, expect, it } from 'vitest' +import { encryptedDynamoDB } from '@/dynamodb' +import { toEncryptedDynamoItem } from '@/dynamodb/helpers' +import { buildEncryptConfig, encryptedTable, types } from '@/eql/v3' +import type { CastAs } from '@/schema' +import type { Encrypted } from '@/types' +import { Encryption as WasmEncryption } from '@/wasm-inline' + +const users = encryptedTable('wasm_v2_read_compat_users', { + email: types.TextEq('email'), +}) + +/** + * A DIFFERENT table, sharing no name or column with `users`, so `unrelatedClient` + * below has never heard of `wasm_v2_read_compat_users`. See the header for why + * that unregistered read is the case worth carrying onto this entry. + */ +const unrelated = encryptedTable('wasm_v2_read_compat_unrelated_v3', { + note: types.TextEq('note'), +}) + +const SECRET = 'ada@example.com' +let fixtureClient: Awaited<ReturnType<typeof newFfiClient>> +let client: Awaited<ReturnType<typeof WasmEncryption>> +let unrelatedClient: Awaited<ReturnType<typeof WasmEncryption>> + +/** + * The WASM factory hard-requires explicit credentials — no dev-profile + * fallback (#663) — so read them straight from the environment. The + * integration harness has already asserted they exist. + */ +const wasmCredentials = () => ({ + workspaceCrn: process.env.CS_WORKSPACE_CRN as string, + accessKey: process.env.CS_CLIENT_ACCESS_KEY as string, + clientId: process.env.CS_CLIENT_ID as string, + clientKey: process.env.CS_CLIENT_KEY as string, +}) + +beforeAll(async () => { + fixtureClient = await newFfiClient({ + encryptConfig: buildEncryptConfig(users), + eqlVersion: 2, + }) + client = await WasmEncryption({ + schemas: [users], + config: wasmCredentials(), + }) + unrelatedClient = await WasmEncryption({ + schemas: [unrelated], + config: wasmCredentials(), + }) +}) + +async function v2Ciphertext(value: string): Promise<Encrypted> { + return (await ffiEncrypt(fixtureClient, { + plaintext: value, + table: users.tableName, + column: users.email.getName(), + })) as Encrypted +} + +describe('wasm-inline v3 client reads stored EQL v2 payloads', () => { + it('decrypts a scalar ciphertext', async () => { + const encrypted = await v2Ciphertext(SECRET) + expect(encrypted).toMatchObject({ v: 2 }) + + expect(unwrapResult(await client.decrypt(encrypted))).toBe(SECRET) + }, 30000) + + it('decrypts a DynamoDB item reconstructed as stored EQL v2', async () => { + const encrypted = await v2Ciphertext(SECRET) + const stored = toEncryptedDynamoItem({ pk: 'a', email: encrypted }, [ + 'email', + ]) + const dynamo = encryptedDynamoDB({ encryptionClient: client }) + + const decrypted = unwrapResult( + await dynamo.decryptModel(stored, users, { storedEqlVersion: 2 }), + ) + expect(decrypted).toMatchObject({ pk: 'a', email: SECRET }) + }, 30000) + + /** + * Keep this reading through `unrelatedClient`. Pointed back at `client` it + * becomes a duplicate of the first case, green forever, and the WASM binding's + * half of the payload-shape-driven invariant goes uncovered. + */ + it('decrypts a ciphertext for a table this client never registered', async () => { + const encrypted = await v2Ciphertext(SECRET) + // Guard against a false pass: genuinely v2 wire, and naming the table the + // reading client was not built with. + expect(encrypted).toMatchObject({ + v: 2, + i: { t: users.tableName, c: 'email' }, + }) + + expect(unwrapResult(await unrelatedClient.decrypt(encrypted))).toBe(SECRET) + }, 30000) +}) + +/** + * The TYPE axis of the same obligation. Everything above reads v2 payloads out + * of a single `types.TextEq` column, so it proves the SURFACES this entry has + * (scalar / DynamoDB item / unregistered table) and nothing about what happens + * to a `date`, a `bigint` or a `boolean` on the way back. + * + * This is NOT a transcription of the shared suite's matrix, and the reason is + * the whole point of carrying it here: per-type reconstruction is a SEPARATE + * implementation on each entry. + * + * - Native rebuilds the whole row at once — `rowReconstructor` calls + * `reconstructDatePaths(row, paths)` (`src/encryption/client-v3.ts`), which + * walks dotted paths and clones intermediates. + * - This entry rebuilds ONE VALUE AT A TIME, inside the batch rebuild loop of + * `decryptModelsBatch` (`src/wasm-inline.ts`): + * `dateFields.has(field.fieldKey) ? reconstructDateValue(item.data) : item.data`, + * against a per-table `Set` of JS property names that `datePropertyPaths` + * precomputes at construction. + * + * The two share `DATE_LIKE_CASTS` (`src/eql/v3/columns.ts`) — the table of WHICH + * casts are date-like — and nothing else. The code that acts on it differs, so + * the native matrix going green says nothing about this path. + * + * `bigint` is the other axis that cannot be inherited from the native run, and + * here it is sharper: on this entry a bigint crosses the wasm-bindgen serde + * boundary as a `js_sys::BigInt` built by protect-ffi's `encode_plaintext` (see + * {@link import('@/wasm-inline').WasmPlaintext}), not as a NAPI value. + * `PlaintextFromKind` promises `bigint` and `V3DecryptedModel` propagates it, so + * the TYPE says `bigint` even on a legacy read. Asserted with an explicit + * `typeof`, not a loose equality: if a v2 payload comes back as a string, that is + * a product defect and this must fail rather than quietly accept it. + * + * Reads go through the MODEL path deliberately. It is the only path with a table + * to resolve `cast_as` from, and therefore the only one that reconstructs + * anything at all; the last two cases pin the single-value boundaries rather + * than hiding them. + * + * The live work is BATCHED exactly as the native matrix batches it: one mega + * table spanning every mintable domain, one `encryptBulk` to mint the whole + * fixture set, one `bulkDecryptModels` to read it back — not ~100 sequential + * round trips. + * + * Domain SELECTION comes from `@cipherstash/test-kit`'s `v2FixturePlan()`, the + * same plan the native suite drives, so the two entries cannot quietly disagree + * about which domains they claim to cover. Exclusions are declared there with a + * written reason (ope-indexed domains have no v2 wire representation; no v2 + * `ste_vec` fixture can be minted at all with cipherstash-client 0.42). + * + * The plan's ACCOUNTING checks — every catalog domain either minted or deferred, + * the deferral list matching its own written rationale, no plaintext axis lost + * without being declared — are deliberately NOT repeated here. They are pure + * functions over `V3_MATRIX` with no entry, no FFI and no network in them, and + * they already run creds-free for every contributor in + * `__tests__/test-kit-v2-fixtures.test.ts`. A third copy inside a suite that + * only collects with live credentials would fire for fewer people, not more. + */ +const matrixPlan = v2FixturePlan() +const matrix = encryptedTable( + 'wasm_v2_read_compat_matrix', + v2FixtureColumns(matrixPlan), +) + +// Flattened to one assertion per (domain, sample), labelled so vitest names the +// exact domain and sample index that failed. +const matrixCases = matrixPlan.cases.map( + (fixture) => + [ + fixture.label, + fixture.slug, + fixture.castAs, + fixture.sample, + fixture.row, + ] as const, +) + +/** + * Assert a decrypted field against its catalog sample, keyed by the plaintext + * axis rather than by `typeof sample`, so the runtime SHAPE is pinned and not + * merely the value. + * + * A near-twin of the native suite's helper, and kept local on purpose: the two + * entries are exactly what this file exists to tell apart, so a shared assertion + * that drifted to accommodate one of them would silently weaken the other. (If + * it is ever hoisted, `@cipherstash/test-kit` is its home — beside the plan it + * asserts against — not an import across two integration suites.) + * + * The `default` arm throws instead of falling back to a loose comparison: a + * catalog domain on a new `cast_as` must arrive with a deliberate assertion, not + * inherit a weak one and look covered. + */ +function expectReconstructed( + actual: unknown, + castAs: CastAs, + sample: unknown, + label: string, +): void { + switch (castAs) { + case 'date': + case 'timestamp': + // `reconstructDateValue` must have run off the v3 table's cast_as even + // though the payload on the wire was v2. + expect(actual, `${label}: expected a reconstructed Date`).toBeInstanceOf( + Date, + ) + expect(actual).toEqual(sample) + return + case 'bigint': + // Not `toEqual`: a string "42" and 42n compare unequal under toBe, but the + // point is the TYPE, and only an explicit typeof states it. + expect(typeof actual, `${label}: expected a native bigint`).toBe('bigint') + expect(actual).toBe(sample) + return + case 'number': + expect(typeof actual, `${label}: expected a number`).toBe('number') + expect(actual).toBe(sample) + return + case 'string': + expect(typeof actual, `${label}: expected a string`).toBe('string') + expect(actual).toBe(sample) + return + case 'boolean': + expect(typeof actual, `${label}: expected a boolean`).toBe('boolean') + expect(actual).toBe(sample) + return + default: + throw new Error( + `${label}: no reconstruction assertion for cast_as "${castAs}". ` + + 'Add one rather than letting a new plaintext axis pass unchecked.', + ) + } +} + +describe('a wasm-inline client reconstructs every plaintext axis from stored EQL v2 payloads', () => { + let matrixFixtureClient: Awaited<ReturnType<typeof newFfiClient>> + let matrixClient: Awaited<ReturnType<typeof WasmEncryption>> + let encrypted: Array<Record<string, Encrypted>> + let decrypted: Array<Record<string, unknown>> + let singleRow: Record<string, unknown> + + beforeAll(async () => { + // Fixtures are minted through the NATIVE binding in v2 mode on both entries, + // and that is not a shortcut: this entry hardcodes `eqlVersion: 3` in its + // factory (`src/wasm-inline.ts`) and rejects the config key outright, so it + // cannot mint its own legacy fixture even in a test. The wire generation is + // a property of the payload, not of the client that reads it — which is the + // claim the whole file exists to prove — and every case below re-checks + // `v: 2` on the bytes it actually asserts against. + matrixFixtureClient = await newFfiClient({ + encryptConfig: buildEncryptConfig(matrix), + eqlVersion: 2, + }) + matrixClient = await WasmEncryption({ + schemas: [matrix], + config: wasmCredentials(), + }) + + // One network call for the whole matrix. `encryptBulk` correlates results + // POSITIONALLY (its payload type carries no id), which is why every case + // below re-checks the identifier it got back. + const payloads = (await ffiEncryptBulk(matrixFixtureClient, { + plaintexts: matrixPlan.cases.map((fixture) => ({ + plaintext: fixture.plaintext as JsPlaintext, + table: matrix.tableName, + column: fixture.slug, + })), + })) as Encrypted[] + + encrypted = v2ModelRows(matrixPlan, payloads) + // The table argument is REQUIRED on this entry — `requiresTableForDecrypt` + // — and it is also what supplies the date paths, so the reconstruction + // under test only happens because `matrix` is registered on the client. + decrypted = unwrapResult( + await matrixClient.bulkDecryptModels(encrypted, matrix), + ) + singleRow = unwrapResult( + await matrixClient.decryptModel(encrypted[0], matrix), + ) + }, 60000) + + it.each( + matrixCases, + )('%s decrypts from a stored EQL v2 payload through the wasm model path', (label, slug, castAs, sample, row) => { + const payload = encrypted[row][slug] + + // THE guard for this block. Without it the whole matrix would pass just as + // happily against v3 fixtures and prove nothing about legacy reads. The + // identifier is checked too: `encryptBulk` correlates positionally, so a + // mis-zipped batch could otherwise assert one domain's sample against + // another domain's ciphertext and go green by coincidence. + expect(payload).toMatchObject({ + v: 2, + i: { t: matrix.tableName, c: slug }, + }) + expect(payload).toHaveProperty('c') + + expectReconstructed(decrypted[row][slug], castAs, sample, label) + }) + + /** + * `decryptModel` and `bulkDecryptModels` are separate wrappers over + * `decryptModelsBatch`, and only the bulk one is exercised above. A row's + * worth of domains through the single path catches the wrapper being wired up + * without its per-model destructure. + */ + it('reconstructs the same values on the single-model decrypt path', () => { + const firstRow = matrixPlan.cases.filter((fixture) => fixture.row === 0) + expect(firstRow.length).toBe(matrixPlan.domains.length) + for (const fixture of firstRow) { + expectReconstructed( + singleRow[fixture.slug], + fixture.castAs, + fixture.sample, + `${fixture.label} (single model)`, + ) + } + }) + + /** + * The boundary, pinned rather than asserted away: date reconstruction is a + * property of the MODEL path, which has a table to read `cast_as` from. This + * entry's single-value `decrypt` takes no table, so it returns the stored + * string — the same contract v3 has here, and the reason the matrix above + * reads through models. + * + * Compared as an instant rather than a literal string: the wire form of a + * `date` plaintext is the FFI's business, and pinning it here would fail on a + * cosmetic change while saying nothing about the contract. + */ + it('returns a date-like scalar as its stored string, not a Date', async () => { + const dateCase = matrixPlan.cases.find( + (fixture) => fixture.castAs === 'date', + ) + if (!dateCase) { + throw new Error( + 'the catalog no longer has a mintable `date` domain to pin this boundary with', + ) + } + + const payload = encrypted[dateCase.row][dateCase.slug] + expect(payload).toMatchObject({ v: 2 }) + + const value = unwrapResult(await matrixClient.decrypt(payload)) + expect(value).not.toBeInstanceOf(Date) + expect(typeof value).toBe('string') + expect(new Date(String(value))).toEqual(dateCase.sample) + }, 30000) + + /** + * The one extra single-value read this block spends a round trip on, and the + * contrast with the case above is the reason. + * + * A `Date` is reconstructed by SDK TypeScript, so the scalar path returning a + * string is a boundary. A `bigint` is not: nothing in this SDK converts it — + * it is built inside the WASM module by protect-ffi's `encode_plaintext` and + * handed out as-is. So `bigint` must survive the SCALAR path too, and the + * scalar path is a different wasm-bindgen export from the batch one + * (`decrypt` vs `decryptBulkFallible`) with its own serde crossing. The matrix + * above only exercises the batch export. + */ + it('returns a native bigint from a v2 payload on the scalar path', async () => { + const bigintCase = matrixPlan.cases.find( + (fixture) => fixture.castAs === 'bigint', + ) + if (!bigintCase) { + throw new Error( + 'the catalog no longer has a mintable `bigint` domain to pin this contract with', + ) + } + + const payload = encrypted[bigintCase.row][bigintCase.slug] + expect(payload).toMatchObject({ v: 2 }) + + const value = unwrapResult(await matrixClient.decrypt(payload)) + expect( + typeof value, + 'wasm scalar decrypt must return a native bigint', + ).toBe('bigint') + expect(value).toBe(bigintCase.sample) + }, 30000) +}) diff --git a/packages/stack/package.json b/packages/stack/package.json index 1f3b5b0c5..694319c85 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -42,9 +42,6 @@ "adapter-kit": [ "./dist/adapter-kit.d.ts" ], - "client": [ - "./dist/client.d.ts" - ], "identity": [ "./dist/identity/index.d.ts" ], @@ -82,16 +79,6 @@ "default": "./dist/index.cjs" } }, - "./client": { - "import": { - "types": "./dist/client.d.ts", - "default": "./dist/client.js" - }, - "require": { - "types": "./dist/client.d.cts", - "default": "./dist/client.cjs" - } - }, "./identity": { "import": { "types": "./dist/identity/index.d.ts", diff --git a/packages/stack/src/client.ts b/packages/stack/src/client.ts deleted file mode 100644 index e7b017308..000000000 --- a/packages/stack/src/client.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Client-safe exports for `@cipherstash/stack`. - * - * This entry point exports types and utilities that can be used in client-side code - * without requiring the `@cipherstash/protect-ffi` native module. - * - * Use this import path: `@cipherstash/stack/client` - * - * `EncryptionClient` is exported as a **type-only** export for use in function - * signatures without pulling in the native FFI dependency. - */ - -export type { EncryptionClient } from '@/encryption' -export type { - EncryptedColumn, - EncryptedField, - EncryptedTable, - EncryptedTableColumn, - InferEncrypted, - InferPlaintext, -} from '@/schema' -// Schema types and utilities - client-safe -export { encryptedColumn, encryptedField, encryptedTable } from '@/schema' diff --git a/packages/stack/src/dynamodb/helpers.ts b/packages/stack/src/dynamodb/helpers.ts index 4ee8efa59..a7d06085a 100644 --- a/packages/stack/src/dynamodb/helpers.ts +++ b/packages/stack/src/dynamodb/helpers.ts @@ -1,9 +1,8 @@ import type { ProtectErrorCode } from '@cipherstash/protect-ffi' import { ProtectError as FfiProtectError } from '@cipherstash/protect-ffi' import { resolveEncryptColumnMap } from '@/encryption/helpers/model-helpers' -import type { AnyV3Table } from '@/eql/v3' +import { DATE_LIKE_CASTS } from '@/eql/v3/columns' import type { EncryptedValue } from '@/types' -import { hasBuildColumnKeyMap } from '@/types' import { logger } from '@/utils/logger' import type { AnyEncryptedTable, EncryptedDynamoDBError } from './types' @@ -15,13 +14,9 @@ export const searchTermAttrSuffix = '__hmac' * stored `__source` attribute. * * `buildColumnKeyMap` is the canonical v3 marker in this codebase — see - * `resolveEqlVersion` (`encryption/index.ts`) and `types.ts`, which document it + * `assertV3Schemas` (`encryption/index.ts`) and `types.ts`, which document it * as *the* signal. Only v3 tables define it. */ -export function isV3Table(table: AnyEncryptedTable): table is AnyV3Table { - return hasBuildColumnKeyMap(table) -} - export class EncryptedDynamoDBErrorImpl extends Error implements EncryptedDynamoDBError @@ -86,8 +81,8 @@ export function handleError( /** * Resolve a decrypt call against either client shape. * - * The nominal `EncryptionClient` and the typed client both return a chainable - * operation carrying `.audit()` on decrypt (the typed client's is a + * `EncryptionClient` returns a chainable operation carrying `.audit()` on + * decrypt (the schema-derived model operation is a * `MappedDecryptOperation`). Chain the audit metadata onto it. * * NOT every client this package accepts does that. `WasmEncryptionClient` @@ -318,21 +313,32 @@ function isStoredEqlPayload(value: unknown): value is StoredEqlPayload { * write and read paths so the two split and rebuild EXACTLY the same set of * attributes — an asymmetry here writes data one side can never reassemble. * - * `columnPaths` are the JS property paths a model's fields are matched on. The - * dotted form is tried first; a v2 grouped `encryptedField('amount')` registers - * the bare leaf, so a nested `amount` falls back to it. v3 always registers the - * full dotted path (`'profile.ssn'`), so it never needs the fallback — and must - * NOT use it, or a nested `note` would match a same-named TOP-LEVEL `note` - * column. Scope the fallback to nested v2 attributes only. + * `columnPaths` are the JS property paths a model's fields are matched on. + * Nested v3 fields are registered by their full dotted path (`profile.ssn`), + * preventing a nested leaf from colliding with a top-level column. */ -export function makeColumnMatcher(isV3: boolean, columnPaths: string[]) { +export function makeColumnMatcher( + columnPaths: string[], + // Stored EQL v2 only. A v2 grouped column registered its build key on the + // BARE LEAF, so a field inside a group was written as + // `<group>.<leaf>__source` while the schema knew it only as `<leaf>`. Exact + // dotted matching alone therefore orphans every such attribute, which reads + // back as raw base64 inside a `{ data }` success. + // + // Deliberately NOT enabled for v3: a v3 table registers full dotted paths so + // that a nested leaf CANNOT collide with a same-named top-level column, and + // matching by bare leaf there rewrote a plaintext sibling as an envelope and + // handed it to the FFI as a decrypt target. + allowBareLeaf = false, +) { + const paths = new Set(columnPaths) return function matchColumn( leaf: string, prefix: string, ): string | undefined { const dotted = prefix ? `${prefix}.${leaf}` : leaf - if (columnPaths.includes(dotted)) return dotted - if (!isV3 && prefix && columnPaths.includes(leaf)) return leaf + if (paths.has(dotted)) return dotted + if (allowBareLeaf && prefix && paths.has(leaf)) return leaf return undefined } } @@ -340,12 +346,8 @@ export function makeColumnMatcher(isV3: boolean, columnPaths: string[]) { export function toEncryptedDynamoItem( encrypted: Record<string, unknown>, encryptedAttrs: string[], - // `false` (v2) by default so existing 2-arg callers keep the v2 bare-leaf - // fallback; the operations pass `isV3Table(table)` so a v3 write splits the - // same columns a v3 read rebuilds. - isV3 = false, ): Record<string, unknown> { - const matchColumn = makeColumnMatcher(isV3, encryptedAttrs) + const matchColumn = makeColumnMatcher(encryptedAttrs) function processValue( attrName: string, @@ -450,7 +452,6 @@ export function toEncryptedDynamoItem( * a column is declared `emailAddress: types.TextEq('email_address')`. */ export type ReadContext = { - isV3: boolean v: 2 | 3 encryptConfig: { tableName: string @@ -464,12 +465,13 @@ export type ReadContext = { } /** Resolve the row-invariant read context for a table, once. */ -export function buildReadContext(schema: AnyEncryptedTable): ReadContext { - const isV3 = isV3Table(schema) +export function buildReadContext( + schema: AnyEncryptedTable, + storedEqlVersion: 2 | 3 = 3, +): ReadContext { const { columnPaths, toColumnName } = resolveEncryptColumnMap(schema) return { - isV3, - v: isV3 ? 3 : 2, + v: storedEqlVersion, encryptConfig: schema.build(), columnPaths, toColumnName, @@ -482,11 +484,25 @@ export function toItemWithEqlPayloads( // Resolved once here for the single-item path; passed in by the bulk path so // it is not rebuilt per item. context: ReadContext = buildReadContext(encryptionSchema), + // Out-param: the ACTUAL dotted paths a bare-leaf-matched date-like column was + // written back to. A grouped v2 field matched as `placedAt` lands at + // `details.placedAt`, but both clients resolve their date columns from the + // REGISTERED paths alone (`rowReconstructor` in `encryption/client-v3.ts`, + // `dateFields` in `wasm-inline.ts`), so neither reconstructs it and the value + // reads back as an ISO string. This is the only layer that knows the alias + // happened; the decrypt operations reconstruct at what it reports. + // + // Per item, never shared across a bulk call: `details.placedAt` may be an + // encrypted date column in one item and an ordinary plaintext string in the + // next, and a shared set would convert the latter. + aliasedDatePaths?: Set<string>, ): Record<string, unknown> { - const { isV3, v, encryptConfig, columnPaths, toColumnName } = context + const { v, encryptConfig, columnPaths, toColumnName } = context // The same matcher the write path splits with, so the two stay symmetric. - const matchColumn = makeColumnMatcher(isV3, columnPaths) + // The bare-leaf fallback is read-only and v2-only: writes are EQL v3 only, so + // the write path never needs it and must stay strict. + const matchColumn = makeColumnMatcher(columnPaths, v === 2) function processValue( attrName: string, @@ -508,10 +524,7 @@ export function toItemWithEqlPayloads( const columnName = attrName.slice(0, -ciphertextAttrSuffix.length) - // Resolve the attribute back to a declared column. `matchColumn` prefers - // the dotted path (`encryptedField('example.protected')`, and v3's dotted - // property form) and falls back to the bare leaf (`encryptedField('amount')` - // under a `details` group), so both authoring conventions resolve. + // Resolve the attribute back to its declared dotted property path. const matched = matchColumn(columnName, prefix) // A stored ciphertext attribute that names no declared column is almost @@ -543,6 +556,22 @@ export function toItemWithEqlPayloads( // path) is detected too. A v3 column builds the same `{ cast_as, indexes }` // shape as a v2 one, so this detection needs no version branch. const columnConfig = encryptConfig.columns[toColumnName(matched)] + + // The bare-leaf fallback fired iff the match is not the attribute's own + // dotted path — an exact match returns `dotted`, the fallback returns the + // leaf under a non-empty prefix. Only date-like casts need this: every + // other kind decrypts to its final type with no per-path work. + const dotted = prefix ? `${prefix}.${columnName}` : columnName + if ( + aliasedDatePaths && + matched !== dotted && + (DATE_LIKE_CASTS as readonly string[]).includes( + columnConfig?.cast_as as string, + ) + ) { + aliasedDatePaths.add(dotted) + } + if (columnConfig?.cast_as === 'json' && columnConfig.indexes.ste_vec) { const stored = attrValue as { h?: unknown; sv?: unknown } return { diff --git a/packages/stack/src/dynamodb/index.ts b/packages/stack/src/dynamodb/index.ts index bc4c5194f..35efc47cf 100644 --- a/packages/stack/src/dynamodb/index.ts +++ b/packages/stack/src/dynamodb/index.ts @@ -1,11 +1,11 @@ import type { EncryptedValue } from '@/types' -import { isV3Table } from './helpers' 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 type { AnyEncryptedTable, + DynamoDBReadOptions, EncryptedDynamoDBConfig, EncryptedDynamoDBInstance, } from './types' @@ -18,55 +18,37 @@ import type { * surfaces only much later as an opaque FFI deserialization error, far from the * misconfiguration that caused it. * - * The client does not expose its EQL wire version directly (it is baked into the - * FFI client at `newClient` time and neither the nominal `EncryptionClient` nor - * the typed `EncryptionV3` wrapper re-surfaces it). The reliable, public signal - * both client shapes DO expose is `getEncryptConfig()`: a v3 table can only be - * encrypted by a client that registered it, and a client built for v3 registers - * it under its `tableName`. So the guard fires only when we can PROVE the table - * is absent from a readable config — never on an unreadable one, to avoid a - * false positive. + * The client does not expose its EQL wire version directly: it is baked into the + * FFI client at `newClient` time and the `EncryptionClient` wrapper does not + * re-surface it. The reliable, public signal it DOES expose is + * `getEncryptConfig()`: a v3 table can only be encrypted by a client that + * registered it, and a client built for v3 registers it under its `tableName`. + * So the guard fires only when we can PROVE the table is absent from a readable + * config — never on an unreadable one, to avoid a false positive. The + * wasm-inline client is exactly that case: it carries no `getEncryptConfig`, so + * this check stays silent there rather than rejecting a valid client. * * This runs at the first point where both the client and a concrete table are in * hand: the operation methods below, when a table is supplied. `encryptedDynamoDB` * itself receives no table, so it cannot check earlier. * - * The one case this could not detect — a client forced to `eqlVersion: 2` while - * registering a v3 table, emitting v2 wire for an `eql_v3_*` domain — is now - * refused by `resolveEqlVersion` at client construction, so no such client can - * reach here. + * The one case this could not detect — a client forced to write v2 wire while + * registering a v3 table, emitting v2 wire for an `eql_v3_*` domain — can no + * longer exist: `Encryption()` rejects a `config.eqlVersion` field outright and + * accepts only v3 tables, so no such client can reach here. */ function assertClientTableVersionMatch( encryptionClient: EncryptedDynamoDBConfig['encryptionClient'], table: AnyEncryptedTable, + storedEqlVersion: 2 | 3 = 3, ): void { - // Only v3 tables carry the strict wire-format requirement this guards. - if (!isV3Table(table)) { - // The v2 read path calls `decryptModel(item)` with NO table on purpose — - // a v2 table means nothing to a v3 client's reconstructor map. That is - // fine for the native clients, which derive the table from the payloads, - // and impossible for the WASM client, whose decrypt requires the table and - // otherwise throws a TypeError about `tableName` from deep inside - // `requireTable`. Refuse the pairing here, where the message can name it - // (#772 review, finding 10). - // - // The message is deliberately operation-NEUTRAL. This guard runs on all - // four operations, so a plain-JS caller (the write overloads are - // `AnyV3Table`-only, so TypeScript never reaches this) hits it on encrypt - // too — where "would fail at the first read" names an operation that never - // ran. The pairing is wrong in both directions anyway: `Encryption()` on - // that entry rejects a v2 schema outright, so the client can never hold - // this table (#788 review). - if ( - (encryptionClient as { requiresTableForDecrypt?: boolean }) - .requiresTableForDecrypt - ) { - throw new Error( - `encryptedDynamoDB: the @cipherstash/stack/wasm-inline client cannot be paired with the legacy EQL v2 table "${table.tableName}". That entry is EQL v3 only — Encryption() there rejects a v2 schema, and its model operations require a table it was initialized with — so both encrypt and decrypt would fail on this table. Use the default @cipherstash/stack entry for tables that still hold EQL v2 items, or migrate the table to an EQL v3 schema (types.* domains) and pass that.`, - ) - } - return - } + // A legacy read reconstructs the v2 envelope around the CURRENT v3 table, so + // the table is forwarded on this path exactly as it is for a v3 read. Both + // entries can serve it: `decrypt` in protect-ffi accepts either wire + // generation regardless of the client's `eqlVersion`, and the table is what + // the reconstructor map needs either way. The registration check below is + // skipped only because a v2 payload proves nothing about v3 mode. + if (storedEqlVersion === 2) return const getEncryptConfig = ( encryptionClient as { @@ -86,6 +68,16 @@ function assertClientTableVersionMatch( ) } +function resolveStoredEqlVersion(options: DynamoDBReadOptions): 2 | 3 { + const version = options.storedEqlVersion ?? 3 + if (version !== 2 && version !== 3) { + throw new Error( + `encryptedDynamoDB: unsupported storedEqlVersion ${String(version)}; expected 2 or 3.`, + ) + } + return version +} + /** * Create an encrypted DynamoDB helper bound to an `EncryptionClient`. * @@ -93,11 +85,9 @@ function assertClientTableVersionMatch( * and `bulkDecryptModels` methods that transparently encrypt/decrypt DynamoDB * items according to the provided table schema. * - * **Encrypt/write is EQL v3 only** — `encryptModel` / `bulkEncryptModels` accept - * only EQL v3 tables (`types.*` domains). **Decrypt still reads existing EQL v2 - * items**: `decryptModel` / `bulkDecryptModels` continue to accept an EQL v2 - * table (`encryptedColumn`/`encryptedField`) so previously stored v2 data - * remains readable. The table decides which wire format is reconstructed on read. + * Every operation uses an EQL v3 table. To read attributes stored as EQL v2, + * pass `{ storedEqlVersion: 2 }` to a decrypt method; the table still supplies + * current column identity and type reconstruction. * * Only equality is meaningful on DynamoDB: an `hm` term is stored alongside the * ciphertext as `<attr>__hmac` and can back a key condition. Ordering and @@ -125,18 +115,26 @@ function assertClientTableVersionMatch( * const encrypted = await dynamo.encryptModel({ email: "a@b.com" }, users) * ``` * - * @example EQL v2 (reading existing deployments — decrypt only) + * @example EQL v2 storage (reading existing deployments) * ```typescript * import { Encryption } from "@cipherstash/stack" + * import { encryptedTable, types } from "@cipherstash/stack/v3" * import { encryptedDynamoDB } from "@cipherstash/stack/dynamodb" - * import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema" * + * // The CURRENT v3 table — there is no v2 builder and no v2-mode client. It + * // supplies column identity and type reconstruction for the legacy read, so + * // keep it declared for as long as v2 items remain in the table. * const users = encryptedTable("users", { - * email: encryptedColumn("email").equality(), + * email: types.TextEq("email"), * }) * * const client = await Encryption({ schemas: [users] }) * const dynamo = encryptedDynamoDB({ encryptionClient: client }) + * + * // `storedItem` is an item written before the v3 cutover. + * const decrypted = await dynamo.decryptModel(storedItem, users, { + * storedEqlVersion: 2, + * }) * ``` */ export function encryptedDynamoDB( @@ -185,20 +183,31 @@ export function encryptedDynamoDB( const decryptModel = <T extends Record<string, unknown>>( item: Record<string, EncryptedValue | unknown>, table: AnyEncryptedTable, + readOptions: DynamoDBReadOptions = {}, ) => { - assertClientTableVersionMatch(encryptionClient, table) - return new DecryptModelOperation<T>(encryptionClient, item, table, options) + const storedEqlVersion = resolveStoredEqlVersion(readOptions) + assertClientTableVersionMatch(encryptionClient, table, storedEqlVersion) + return new DecryptModelOperation<T>( + encryptionClient, + item, + table, + readOptions, + options, + ) } const bulkDecryptModels = <T extends Record<string, unknown>>( items: Record<string, EncryptedValue | unknown>[], table: AnyEncryptedTable, + readOptions: DynamoDBReadOptions = {}, ) => { - assertClientTableVersionMatch(encryptionClient, table) + const storedEqlVersion = resolveStoredEqlVersion(readOptions) + assertClientTableVersionMatch(encryptionClient, table, storedEqlVersion) return new BulkDecryptModelsOperation<T>( encryptionClient, items, table, + readOptions, options, ) } @@ -220,6 +229,7 @@ export type { AnyEncryptedTable, DecryptedAttributes, DynamoDBEncryptionClient, + DynamoDBReadOptions, EncryptedAttributes, EncryptedDynamoDBConfig, EncryptedDynamoDBError, diff --git a/packages/stack/src/dynamodb/operations/bulk-decrypt-models.ts b/packages/stack/src/dynamodb/operations/bulk-decrypt-models.ts index 4f1f4f21a..9cc477132 100644 --- a/packages/stack/src/dynamodb/operations/bulk-decrypt-models.ts +++ b/packages/stack/src/dynamodb/operations/bulk-decrypt-models.ts @@ -1,10 +1,10 @@ import { type Result, withResult } from '@byteslice/result' +import { reconstructDatePaths } from '@/eql/v3/date-reconstruction' import type { Decrypted, EncryptedValue } from '@/types' import { logger } from '@/utils/logger' import { buildReadContext, handleError, - isV3Table, resolveDecryptResult, throwPreservingCode, toItemWithEqlPayloads, @@ -13,6 +13,7 @@ import type { AnyEncryptedTable, CallableEncryptionClient, DynamoDBEncryptionClient, + DynamoDBReadOptions, EncryptedDynamoDBError, } from '../types' import { @@ -26,17 +27,20 @@ export class BulkDecryptModelsOperation< private encryptionClient: DynamoDBEncryptionClient private items: Record<string, EncryptedValue | unknown>[] private table: AnyEncryptedTable + private readOptions: DynamoDBReadOptions constructor( encryptionClient: DynamoDBEncryptionClient, items: Record<string, EncryptedValue | unknown>[], table: AnyEncryptedTable, + readOptions: DynamoDBReadOptions = {}, options?: DynamoDBOperationOptions, ) { super(options) this.encryptionClient = encryptionClient this.items = items this.table = table + this.readOptions = readOptions } public async execute(): Promise< @@ -47,20 +51,25 @@ export class BulkDecryptModelsOperation< async () => { // Resolve the table's read context once, not once per item — `build()` // and the column map are row-invariant. - const readContext = buildReadContext(this.table) - const itemsWithEqlPayloads = this.items.map((item) => - toItemWithEqlPayloads(item, this.table, readContext), + const storedEqlVersion = this.readOptions.storedEqlVersion ?? 3 + const readContext = buildReadContext(this.table, storedEqlVersion) + // One set PER ITEM, not one for the batch: `details.placedAt` can be an + // encrypted date column in one item and an ordinary plaintext string in + // the next, and a shared set would convert the latter. See the + // `aliasedDatePaths` note on `toItemWithEqlPayloads`. + const aliasedDatePathsPerItem = this.items.map(() => new Set<string>()) + const itemsWithEqlPayloads = this.items.map((item, index) => + toItemWithEqlPayloads( + item, + this.table, + readContext, + aliasedDatePathsPerItem[index], + ), ) const client = this.encryptionClient as CallableEncryptionClient const decryptResult = await resolveDecryptResult<Decrypted<T>[]>( - // Conditional for the same reason as `decryptModel` — see the note - // there. A v2 table forwarded to a v3-configured typed client is - // rejected by its reconstructor lookup, breaking the v2 read path - // this adapter documents as supported. - isV3Table(this.table) - ? client.bulkDecryptModels(itemsWithEqlPayloads, this.table) - : client.bulkDecryptModels(itemsWithEqlPayloads), + client.bulkDecryptModels(itemsWithEqlPayloads, this.table), this.getAuditData(), ) @@ -68,7 +77,15 @@ export class BulkDecryptModelsOperation< throwPreservingCode(decryptResult.failure) } - return decryptResult.data + // Rows come back in request order, so each item's aliased paths apply to + // the row at the same index. + return decryptResult.data.map((row, index) => { + const paths = aliasedDatePathsPerItem[index] + if (!paths || paths.size === 0) return row + return reconstructDatePaths(row as Record<string, unknown>, [ + ...paths, + ]) as Decrypted<T> + }) }, (error) => handleError(error, 'bulkDecryptModels', { diff --git a/packages/stack/src/dynamodb/operations/bulk-encrypt-models.ts b/packages/stack/src/dynamodb/operations/bulk-encrypt-models.ts index 063d83545..8ac389690 100644 --- a/packages/stack/src/dynamodb/operations/bulk-encrypt-models.ts +++ b/packages/stack/src/dynamodb/operations/bulk-encrypt-models.ts @@ -4,7 +4,6 @@ import { logger } from '@/utils/logger' import { deepClone, handleError, - isV3Table, resolveEncryptResult, throwPreservingCode, toEncryptedDynamoItem, @@ -69,10 +68,8 @@ export class BulkEncryptModelsOperation< this.table, ) - const isV3 = isV3Table(this.table) return data.map( - (encrypted) => - toEncryptedDynamoItem(encrypted, encryptedAttrs, isV3) as T, + (encrypted) => toEncryptedDynamoItem(encrypted, encryptedAttrs) as T, ) }, (error) => diff --git a/packages/stack/src/dynamodb/operations/decrypt-model.ts b/packages/stack/src/dynamodb/operations/decrypt-model.ts index 1a725e56e..f28cf178e 100644 --- a/packages/stack/src/dynamodb/operations/decrypt-model.ts +++ b/packages/stack/src/dynamodb/operations/decrypt-model.ts @@ -1,9 +1,10 @@ import { type Result, withResult } from '@byteslice/result' +import { reconstructDatePaths } from '@/eql/v3/date-reconstruction' import type { Decrypted, EncryptedValue } from '@/types' import { logger } from '@/utils/logger' import { + buildReadContext, handleError, - isV3Table, resolveDecryptResult, throwPreservingCode, toItemWithEqlPayloads, @@ -12,6 +13,7 @@ import type { AnyEncryptedTable, CallableEncryptionClient, DynamoDBEncryptionClient, + DynamoDBReadOptions, EncryptedDynamoDBError, } from '../types' import { @@ -25,17 +27,20 @@ export class DecryptModelOperation< private encryptionClient: DynamoDBEncryptionClient private item: Record<string, EncryptedValue | unknown> private table: AnyEncryptedTable + private readOptions: DynamoDBReadOptions constructor( encryptionClient: DynamoDBEncryptionClient, item: Record<string, EncryptedValue | unknown>, table: AnyEncryptedTable, + readOptions: DynamoDBReadOptions = {}, options?: DynamoDBOperationOptions, ) { super(options) this.encryptionClient = encryptionClient this.item = item this.table = table + this.readOptions = readOptions } public async execute(): Promise< @@ -44,19 +49,24 @@ export class DecryptModelOperation< logger.debug('DynamoDB: decrypting model.') return await withResult( async () => { - const withEqlPayloads = toItemWithEqlPayloads(this.item, this.table) + const storedEqlVersion = this.readOptions.storedEqlVersion ?? 3 + // A grouped v2 date column is matched by its bare leaf but lands at a + // nested path the client's reconstructor does not know about — see the + // `aliasedDatePaths` note on `toItemWithEqlPayloads`. Empty on a v3 read, + // where the fallback never fires. + const aliasedDatePaths = new Set<string>() + const withEqlPayloads = toItemWithEqlPayloads( + this.item, + this.table, + buildReadContext(this.table, storedEqlVersion), + aliasedDatePaths, + ) const client = this.encryptionClient as CallableEncryptionClient const decryptResult = await resolveDecryptResult<Decrypted<T>>( - // The typed client REQUIRES the table; the nominal one derives it - // from the payloads and needs no second argument. Forwarding a v2 - // table unconditionally breaks this adapter's documented v2 read - // path: a v3-configured typed client looks the table up in its own - // reconstructor map, does not find it, and fails. Only a v3 table is - // ever meaningful to that lookup, so only a v3 table is passed. - isV3Table(this.table) - ? client.decryptModel(withEqlPayloads, this.table) - : client.decryptModel(withEqlPayloads), + // The table is always the registered v3 descriptor, even when the + // stored envelope is v2. Passing it preserves Date reconstruction. + client.decryptModel(withEqlPayloads, this.table), this.getAuditData(), ) @@ -64,7 +74,14 @@ export class DecryptModelOperation< throwPreservingCode(decryptResult.failure) } - return decryptResult.data + if (aliasedDatePaths.size === 0) return decryptResult.data + + // `reconstructDatePaths` shallow-clones down each path, so the client's + // own result object is never mutated. + return reconstructDatePaths( + decryptResult.data as Record<string, unknown>, + [...aliasedDatePaths], + ) as Decrypted<T> }, (error) => handleError(error, 'decryptModel', { diff --git a/packages/stack/src/dynamodb/operations/encrypt-model.ts b/packages/stack/src/dynamodb/operations/encrypt-model.ts index 18c5840a5..3bf1683af 100644 --- a/packages/stack/src/dynamodb/operations/encrypt-model.ts +++ b/packages/stack/src/dynamodb/operations/encrypt-model.ts @@ -4,7 +4,6 @@ import { logger } from '@/utils/logger' import { deepClone, handleError, - isV3Table, resolveEncryptResult, throwPreservingCode, toEncryptedDynamoItem, @@ -66,11 +65,7 @@ export class EncryptModelOperation< this.table, ) - return toEncryptedDynamoItem( - data, - encryptedAttrs, - isV3Table(this.table), - ) as T + return toEncryptedDynamoItem(data, encryptedAttrs) as T }, (error) => handleError(error, 'encryptModel', { diff --git a/packages/stack/src/dynamodb/types.ts b/packages/stack/src/dynamodb/types.ts index c84af7b2c..7c2af2b90 100644 --- a/packages/stack/src/dynamodb/types.ts +++ b/packages/stack/src/dynamodb/types.ts @@ -1,5 +1,5 @@ import type { ProtectErrorCode } from '@cipherstash/protect-ffi' -import type { EncryptionClient } from '@/encryption' +import type { EncryptionClient } from '@/encryption/client-v3' import type { AnyV3Table, EncryptedTable as EncryptedV3Table, @@ -8,45 +8,32 @@ import type { QueryTypesForColumn, V3ModelInput, } from '@/eql/v3' -import type { EncryptedTable, EncryptedTableColumn } from '@/schema' -import type { EncryptedValue } from '@/types' import type { ciphertextAttrSuffix, searchTermAttrSuffix } from './helpers' 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' -/** - * A table this adapter accepts: either an EQL v2 table (`encryptedTable` + - * `encryptedColumn`/`encryptedField` from `@cipherstash/stack/schema`) or an - * EQL v3 one (`encryptedTable` + `types.*` from `@cipherstash/stack/eql/v3`). - * - * This union is the adapter's widest input type — the erased view the internal - * `CallableEncryptionClient` is declared against. It is NOT the public contract: - * the surface split the two versions apart. `encryptModel` / - * `bulkEncryptModels` narrowed to `AnyV3Table` (the v2 write overloads were - * removed, so a v2 encrypt call site does have to change); `decryptModel` / - * `bulkDecryptModels` still take either, so items stored under v2 stay - * readable. See {@link EncryptedDynamoDBInstance} for the overloads that decide - * this per method. - */ -export type AnyEncryptedTable = - | EncryptedTable<EncryptedTableColumn> - | AnyV3Table +/** An EQL v3 table accepted by every adapter operation. */ +export type AnyEncryptedTable = AnyV3Table + +export type DynamoDBReadOptions = { + /** Wire generation of the payload before DynamoDB stripped its envelope. */ + storedEqlVersion?: 2 | 3 +} /** * The client capability this adapter consumes, declared structurally so it is - * satisfied by the nominal {@link EncryptionClient} AND by the - * `TypedEncryptionClient` that `EncryptionV3` returns, neither needing a cast. + * satisfied by {@link EncryptionClient} and by the WASM client. * Mirrors the approach the Drizzle v3 operators take for the same reason: a - * nominal `TypedEncryptionClient<S>` parameter would reject a client built for + * nominal `EncryptionClient<S>` parameter would reject a client built for * a narrower schema tuple. * - * Both NATIVE clients return a chainable operation on every path — the nominal - * client's `DecryptModelOperation` and the typed wrapper's - * `MappedDecryptOperation` each carry `.audit()` (the typed wrapper also takes - * the table as a second argument). The operation classes handle both; see - * `DecryptModelOperation` and `resolveDecryptResult`. + * The NATIVE client returns a chainable operation on every path: its + * decrypt-model methods hand back a `MappedDecryptOperation` wrapping the + * underlying `DecryptModelOperation`, and both of those carry `.audit()`. The + * operation classes handle either shape; see `DecryptModelOperation` and + * `resolveDecryptResult`. * * The wasm-inline client does not, on EITHER path: its encrypt and decrypt are * plain `async` methods returning a bare `Promise<WasmResult>`, so audit @@ -55,10 +42,10 @@ export type AnyEncryptedTable = * therefore a bug, not just a lost audit record; the encrypt path made exactly * that mistake and failed every v3 write on this entry (#788 review follow-up). * - * Its EQL v2 path is refused outright by `assertClientTableVersionMatch` — the - * v2 read relies on calling decrypt WITHOUT a table, and that entry's - * `Encryption()` rejects a v2 schema anyway, so the pairing is wrong in both - * directions. + * Its EQL v2 READ path is supported, however. The legacy read reconstructs the + * v2 envelope around the current v3 table and forwards that table like any + * other read, so nothing on this entry needs a v2 schema — which is just as + * well, since its `Encryption()` rejects one. */ export type DynamoDBEncryptionClient = { encryptModel(input: never, table: never): unknown @@ -71,15 +58,15 @@ export type DynamoDBEncryptionClient = { * @internal Callable view of {@link DynamoDBEncryptionClient}. * * The public type declares `never` operands so both client shapes satisfy it - * without a cast; a callable signature cannot be written that both a generic - * `EncryptionClient` method and a generic `TypedEncryptionClient` method - * satisfy. The operation classes therefore cast to this shape at the call site + * without a cast; a callable signature cannot be written that both the generic + * native `EncryptionClient` and the WASM client methods satisfy. The operation + * classes therefore cast to this shape at the call site * — the same split the Drizzle v3 operators use. * * The returns are intentionally untyped on ALL FOUR members. The clients - * disagree about what an operation even is: the nominal client returns a - * chainable `EncryptModelOperation` / `DecryptModelOperation`, the typed client - * a `MappedDecryptOperation`, and the wasm-inline client a bare + * disagree about what an operation even is: the native client returns a + * chainable `EncryptModelOperation` / `DecryptModelOperation`, adapters can + * return a `MappedDecryptOperation`, and the wasm-inline client returns a bare * `Promise<WasmResult>` with no `.audit()` anywhere on it. * * Declaring a chainable shape here asserts an `.audit()` that the wasm entry @@ -110,11 +97,11 @@ export type CallableEncryptionClient = { export interface EncryptedDynamoDBConfig { /** - * The client from `Encryption(...)` (or the deprecated `EncryptionV3(...)` - * alias). For an EQL v3 schema set `Encryption` auto-selects the v3 wire format - * and returns the typed client — no `config: { eqlVersion: 3 }` needed. + * The client returned by `Encryption(...)` for this table. */ - encryptionClient: EncryptionClient | DynamoDBEncryptionClient + encryptionClient: + | EncryptionClient<readonly AnyV3Table[]> + | DynamoDBEncryptionClient options?: { logger?: { error: (message: string, error: Error) => void @@ -273,12 +260,8 @@ export interface EncryptedDynamoDBInstance { decryptModel<Table extends AnyV3Table, T extends Record<string, unknown>>( item: T, table: Table, + options?: DynamoDBReadOptions, ): DecryptModelOperation<DecryptedAttributes<Table, T>> - /** EQL v2. Unchanged. */ - decryptModel<T extends Record<string, unknown>>( - item: Record<string, EncryptedValue | unknown>, - table: EncryptedTable<EncryptedTableColumn>, - ): DecryptModelOperation<T> /** EQL v3. See {@link EncryptedDynamoDBInstance.decryptModel}. */ bulkDecryptModels< @@ -287,10 +270,6 @@ export interface EncryptedDynamoDBInstance { >( items: T[], table: Table, + options?: DynamoDBReadOptions, ): BulkDecryptModelsOperation<DecryptedAttributes<Table, T>> - /** EQL v2. Unchanged. */ - bulkDecryptModels<T extends Record<string, unknown>>( - items: Record<string, EncryptedValue | unknown>[], - table: EncryptedTable<EncryptedTableColumn>, - ): BulkDecryptModelsOperation<T> } diff --git a/packages/stack/src/encryption/client-v3.ts b/packages/stack/src/encryption/client-v3.ts new file mode 100644 index 000000000..68dd046ea --- /dev/null +++ b/packages/stack/src/encryption/client-v3.ts @@ -0,0 +1,491 @@ +import type { + AnyV3Table, + ColumnsOf, + PlaintextForColumn, + QueryableColumnsOf, + QueryTypesForColumn, + V3DecryptedModel, + V3EncryptedModel, + V3ModelInput, +} from '@/eql/v3' +import { DATE_LIKE_CASTS } from '@/eql/v3/columns' +import { reconstructDatePaths } from '@/eql/v3/date-reconstruction' +import { type EncryptionError, EncryptionErrorTypes } from '@/errors' +import type { LockContextInput } from '@/identity' +import type { + BulkDecryptPayload, + BulkEncryptPayload, + Decrypted, + Encrypted, + EncryptedReturnType, + EncryptOptions, +} from '@/types' +// Every binding from `./index` is imported TYPE-ONLY — `Encryption` included, +// since it is referenced only from the TSDoc {@link}s below (same trick as the +// `LockContext` import in `./index`). Keeping this side type-only is what makes +// the pair acyclic at runtime: values flow one way only, `./index` → here, for +// `createEncryptionClient`. A value import of `Encryption` here would reinstate +// a genuine module-eval cycle for the sake of a binding nothing consumes. +import type { + BatchEncryptQueryOperation, + BulkDecryptModelsOperation, + BulkDecryptOperation, + BulkEncryptModelsOperation, + BulkEncryptOperation, + DecryptModelOperation, + DecryptOperation, + Encryption, + EncryptModelOperation, + EncryptOperation, + EncryptQueryOperation, +} from './index' +import { + type AuditableDecryptModelOperation, + type LockBoundDecryptModelOperation, + MappedDecryptOperation, +} from './operations/mapped-decrypt' + +/** + * The structural VIEW of the native client that the wrapper below consumes — + * only the members it forwards to, with operands erased to `never` so a client + * built for any schema tuple satisfies it. + * + * Deliberately not the concrete class: that class (`NativeEncryptionClient` in + * `./index`) is not exported, and naming it here would make the two modules + * mutually dependent at the type level for no gain. Named for the role it plays + * — the thing being wrapped — so it does not read as a second declaration of + * the implementation class. + */ +type UnderlyingNativeClient = { + encrypt(plaintext: never, opts: never): EncryptOperation + encryptQuery( + plaintextOrTerms: never, + opts?: never, + ): EncryptQueryOperation | BatchEncryptQueryOperation + encryptModel(input: never, table: never): unknown + bulkEncryptModels(input: never, table: never): unknown + decrypt(encrypted: Encrypted): DecryptOperation + decryptModel( + input: Record<string, unknown>, + ): DecryptModelOperation<Record<string, unknown>> + bulkDecryptModels( + input: Array<Record<string, unknown>>, + ): BulkDecryptModelsOperation<Record<string, unknown>> + bulkEncrypt( + plaintexts: BulkEncryptPayload, + opts: EncryptOptions, + ): BulkEncryptOperation + bulkDecrypt(payloads: BulkDecryptPayload): BulkDecryptOperation + getEncryptConfig(): import('@/schema').EncryptConfig | undefined +} + +type QueryTermForTable<Table extends AnyV3Table> = + QueryableColumnsOf<Table> extends infer Col + ? Col extends QueryableColumnsOf<Table> + ? { + value: PlaintextForColumn<Col> + table: Table + column: Col + queryType?: QueryTypesForColumn<Col> + returnType?: EncryptedReturnType + } + : never + : never + +type QueryTermForSchemas<S extends readonly AnyV3Table[]> = + S[number] extends infer Table + ? Table extends AnyV3Table + ? QueryTermForTable<Table> + : never + : never + +type BulkEncryptPayloadFor<Col> = Array<{ + id?: string + plaintext: PlaintextForColumn<Col> | null +}> + +/** + * The client type {@link Encryption} resolves to for the schema tuple `S`: a + * strongly-typed view over the native FFI-backed client, for EQL v3 schemas. + * + * Every method derives its types from the concrete `table` / `column` builder + * arguments (which carry their branded types at the call site), so: + * - `encrypt` / `encryptQuery` pin the plaintext to the column's domain type + * (`text → string`, `timestamp → Date`, …); + * - `encryptQuery` additionally constrains `queryType` to the column's + * capabilities and rejects storage-only columns outright; + * - `encryptModel` / `bulkEncryptModels` validate schema-column fields against + * their inferred plaintext type (passthrough fields are untouched) and return + * a precise encrypted model; + * - `decryptModel` / `bulkDecryptModels` return the precise plaintext model, + * reconstructing `Date` values from the encrypt-config `cast_as`. + * + * This is **the** way to name the client — reach for it whenever you need to + * declare a variable, field or return type before the `await` that produces it: + * + * ```typescript + * const users = encryptedTable("users", { email: types.TextSearch("email") }) + * + * let client: EncryptionClient<readonly [typeof users]> + * client = await Encryption({ schemas: [users] }) + * ``` + * + * For code that is generic over its schemas — integration adapters that build a + * table per test family, say — name the loose array: + * + * ```typescript + * let client: EncryptionClient<readonly AnyV3Table[]> + * ``` + * + * That keeps `table` and `column` arguments checked, but NOT the model input: + * with `S` loose, `V3ModelInput<AnyV3Table, T>` cannot resolve a per-column + * plaintext, so `encryptModel` / `bulkEncryptModels` reject a + * `Record<string, unknown>` model. An adapter holding untyped rows needs a cast + * at that boundary (`@cipherstash/stack-supabase` has exactly one, for exactly + * this reason). Name a concrete tuple wherever the schema IS known statically — + * that is the only form with full model typing. + * + * Prefer this named type to `Awaited<ReturnType<typeof Encryption>>` when the + * concrete schema tuple matters: TypeScript's `ReturnType` reads the final + * overload and therefore produces the intentionally widened default client. + * + * @typeParam S - the tuple of registered v3 tables; `table` arguments must be a + * member of this tuple. + */ +export interface EncryptionClient< + S extends readonly AnyV3Table[] = readonly AnyV3Table[], +> { + encrypt<Table extends S[number], Col extends ColumnsOf<Table>>( + plaintext: PlaintextForColumn<Col>, + opts: { table: Table; column: Col }, + ): EncryptOperation + + encryptQuery< + Table extends S[number], + Col extends QueryableColumnsOf<Table>, + QT extends QueryTypesForColumn<Col> = QueryTypesForColumn<Col>, + >( + plaintext: PlaintextForColumn<Col>, + opts: { + table: Table + column: Col + queryType?: QT + returnType?: EncryptedReturnType + }, + ): EncryptQueryOperation + + /** Batch query terms remain correlated by table, column, value and capability. */ + encryptQuery( + terms: readonly QueryTermForSchemas<S>[], + ): BatchEncryptQueryOperation + + encryptModel<Table extends S[number], T extends Record<string, unknown>>( + input: V3ModelInput<Table, T>, + table: Table, + ): EncryptModelOperation<V3EncryptedModel<Table, T>> + + bulkEncryptModels<Table extends S[number], T extends Record<string, unknown>>( + input: Array<V3ModelInput<Table, T>>, + table: Table, + ): BulkEncryptModelsOperation<V3EncryptedModel<Table, T>> + + /** + * Decrypt a single value. Cannot be strongly typed — a lone ciphertext carries + * no column identity — so it resolves to the FFI plaintext union unchanged. + */ + decrypt(encrypted: Encrypted): DecryptOperation + + /** + * Decrypt a model, returning the precise plaintext shape for `table`. `Date` + * columns are reconstructed from the encrypt-config `cast_as`. + * + * Pass `lockContext` to decrypt identity-bound data — the same context that + * was supplied at encrypt time must be provided here (or chain + * `.withLockContext()` on the returned operation). + * + * Returns a chainable {@link AuditableDecryptModelOperation}: await it for the + * `Result`, or chain `.audit({ metadata })` / `.withLockContext()` first. The + * per-row `Date` reconstruction is applied to the successful result. + * + * Passing `lockContext` positionally returns the + * {@link LockBoundDecryptModelOperation} instead, which offers `.audit()` but + * not `.withLockContext()` — a context binds exactly once, and binding twice + * throws. + */ + decryptModel<Table extends S[number], T extends Record<string, unknown>>( + input: T, + table: Table, + lockContext: LockContextInput | undefined, + ): LockBoundDecryptModelOperation<V3DecryptedModel<Table, T>> + decryptModel<Table extends S[number], T extends Record<string, unknown>>( + input: T, + table: Table, + ): AuditableDecryptModelOperation<V3DecryptedModel<Table, T>> + + /** + * Table-less form: decrypt whatever encrypted fields the model carries, with + * no `Date` reconstruction (there is no `cast_as` to reconstruct from) and no + * precise plaintext shape. + * + * This is the read path for rows that predate this client's schemas — legacy + * **EQL v2** models above all, whose table is not, and cannot be, a member of + * `S`. It is also the arity the pre-v3 client exposed, so callers and adapters + * written against that shape keep compiling. The runtime has always accepted + * the one-arg call; without this signature the type layer forbids the very + * compatibility the client promises. Prefer the two-arg form whenever the + * table IS registered. + */ + decryptModel<T extends Record<string, unknown>>( + input: T, + ): AuditableDecryptModelOperation<Decrypted<T>> + + bulkDecryptModels<Table extends S[number], T extends Record<string, unknown>>( + input: Array<T>, + table: Table, + lockContext: LockContextInput | undefined, + ): LockBoundDecryptModelOperation<Array<V3DecryptedModel<Table, T>>> + bulkDecryptModels<Table extends S[number], T extends Record<string, unknown>>( + input: Array<T>, + table: Table, + ): AuditableDecryptModelOperation<Array<V3DecryptedModel<Table, T>>> + + /** Table-less bulk form — see the one-arg {@link decryptModel} overload. */ + bulkDecryptModels<T extends Record<string, unknown>>( + input: Array<T>, + ): AuditableDecryptModelOperation<Array<Decrypted<T>>> + + bulkEncrypt<Table extends S[number], Col extends ColumnsOf<Table>>( + plaintexts: BulkEncryptPayloadFor<Col>, + opts: { table: Table; column: Col }, + ): BulkEncryptOperation + bulkDecrypt(payloads: BulkDecryptPayload): BulkDecryptOperation + getEncryptConfig(): ReturnType<UnderlyingNativeClient['getEncryptConfig']> +} + +/** + * Build a per-row reconstructor of `Date` values from the table's + * encrypt-config `cast_as`. The FFI returns `JsPlaintext` + * (string/number/boolean/…) with no `Date`, so those columns arrive as their + * serialized form and are rebuilt here. Safe (idempotent) if the FFI ever + * returns `Date` directly: `new Date(date)` is a no-op. + * + * A factory rather than a `(row, table)` function so the table config — + * row-invariant, but non-trivial to build — is derived once per call site, + * not once per row on the bulk path. + * + * NOTE: only date-like casts need per-row reconstruction. `bigint` (int8) + * needs none — protect-ffi 0.28 returns a native JS `bigint` on decrypt + * (and bounds-checks/encodes it on encrypt), so those columns pass through + * unchanged, exactly like `string`/`number`/`boolean`. + */ +function rowReconstructor( + table: AnyV3Table, +): (row: Record<string, unknown>) => Record<string, unknown> { + // The decrypted row is keyed by JS property name, but `cast_as` lives on the + // config keyed by DB name — bridge the two via the table's property→DB map. + const { columns } = table.build() + const propToDb = table.buildColumnKeyMap() + // Only date-like columns need per-row work; resolve them up front. + const dateProperties = Object.entries(propToDb) + .filter(([, dbName]) => { + const castAs = columns[dbName]?.cast_as + // Date-like casts share one source of truth with the type-level + // reconstruction (`PlaintextFromKind`) — see `DATE_LIKE_CASTS`. + return (DATE_LIKE_CASTS as readonly string[]).includes(castAs as string) + }) + .map(([property]) => property) + + return (row) => reconstructDatePaths(row, dateProperties) +} + +/** + * Wrap an already-built native FFI client (`UnderlyingNativeClient`) in an + * {@link EncryptionClient} for the given v3 schemas. Zero runtime cost for the + * encrypt/query paths (the underlying operations are returned unchanged); the + * decrypt-model paths add a per-column `Date` reconstruction step. + * + * The `schemas` are captured with a `const` type parameter so array-literal + * widening does not collapse per-table inference. + */ +export function createEncryptionClient<const S extends readonly AnyV3Table[]>( + client: UnderlyingNativeClient, + ...schemas: S +): EncryptionClient<S> { + // Precompute one row reconstructor per schema table at construction. This runs + // each table's `build()` — which throws on duplicate DB column names — ONCE, + // here, off the Result-returning decrypt path. `decryptModel`/ + // `bulkDecryptModels` therefore never call `build()` (whose throw would surface + // as a promise rejection and break their `Promise<Result<…>>` contract) and no + // longer rebuild the row-invariant config on every call. + // Keyed by `tableName`, not table object identity: `AnyV3Table` is + // structurally typed, so a table re-imported from another module (or rebuilt + // after an HMR reload) satisfies `Table extends S[number]` yet is a different + // object. Identity keying would fail those valid calls. `tableName` is the + // semantic identity the FFI encrypt config and `build()` already key on. + const reconstructors = new Map< + string, + (row: Record<string, unknown>) => Record<string, unknown> + >() + for (const table of schemas) { + reconstructors.set(table.tableName, rowReconstructor(table)) + } + + // A table not among the schemas this client was initialized with has no + // precomputed reconstructor. Return a Result failure rather than building one + // inline, which could throw and reject the Result-shaped decrypt promise. + const unknownTableFailure: { failure: EncryptionError } = { + failure: { + type: EncryptionErrorTypes.DecryptionError, + message: + '[eql/v3]: decryptModel received a table this client was not initialized with — pass one of the tables given to Encryption({ schemas })', + }, + } + + // Pass-through maps for the table-less one-arg decrypt call, where `table` is + // absent: decrypt WITHOUT date reconstruction, because with no table there is + // no `cast_as` to reconstruct from. This client is what `Encryption` returns + // for every v3 schema set, so generic consumers — and the legacy EQL v2 read + // path, whose table is not in `S` — can call `decryptModel(x)` / + // `bulkDecryptModels(xs)` with no table. Degrade gracefully instead of + // dereferencing `undefined.tableName`. + const passthroughRow = (row: Record<string, unknown>) => row + const passthroughRows = (rows: Array<Record<string, unknown>>) => rows + + // Overloaded so the implementation is checked against BOTH forms directly — + // no whole-value cast. The two public signatures mirror the interface member; + // the hidden implementation signature is broad and forwards to the underlying + // native client (which routes to the batch operation when no `opts` are + // supplied). + // Only the forwarded args are `as never`, exactly as the sibling wrappers + // below: one forwarding body cannot re-derive the public client's per-column + // signatures. + function encryptQuery< + Table extends S[number], + Col extends QueryableColumnsOf<Table>, + QT extends QueryTypesForColumn<Col> = QueryTypesForColumn<Col>, + >( + plaintext: PlaintextForColumn<Col>, + opts: { + table: Table + column: Col + queryType?: QT + returnType?: EncryptedReturnType + }, + ): EncryptQueryOperation + function encryptQuery( + terms: readonly QueryTermForSchemas<S>[], + ): BatchEncryptQueryOperation + function encryptQuery( + plaintextOrTerms: unknown, + opts?: unknown, + ): EncryptQueryOperation | BatchEncryptQueryOperation { + return client.encryptQuery(plaintextOrTerms as never, opts as never) + } + + // Overloaded declarations for the same reason as `encryptQuery` above: the + // table-ful and table-less forms have different return types, and a single + // arrow in the object literal cannot present both. + function decryptModel< + Table extends S[number], + T extends Record<string, unknown>, + >( + input: T, + table: Table, + lockContext: LockContextInput | undefined, + ): LockBoundDecryptModelOperation<V3DecryptedModel<Table, T>> + function decryptModel< + Table extends S[number], + T extends Record<string, unknown>, + >( + input: T, + table: Table, + ): AuditableDecryptModelOperation<V3DecryptedModel<Table, T>> + function decryptModel<T extends Record<string, unknown>>( + input: T, + ): AuditableDecryptModelOperation<Decrypted<T>> + function decryptModel( + input: Record<string, unknown>, + table?: AnyV3Table, + lockContext?: LockContextInput, + ): AuditableDecryptModelOperation<never> { + // `table` is absent on the table-less one-arg call (see `passthroughRow`). + // Given a table: reconstruct dates from its cast_as, or — if it was never + // registered — leave `map` undefined so the mapped op resolves to + // `unknownTableFailure` on execute. + const reconstruct = table + ? reconstructors.get(table.tableName) + : passthroughRow + const op = client.decryptModel(input) + const base = lockContext ? op.withLockContext(lockContext) : op + return new MappedDecryptOperation( + base, + reconstruct, + unknownTableFailure, + ) as never + } + + function bulkDecryptModels< + Table extends S[number], + T extends Record<string, unknown>, + >( + input: Array<T>, + table: Table, + lockContext: LockContextInput | undefined, + ): LockBoundDecryptModelOperation<Array<V3DecryptedModel<Table, T>>> + function bulkDecryptModels< + Table extends S[number], + T extends Record<string, unknown>, + >( + input: Array<T>, + table: Table, + ): AuditableDecryptModelOperation<Array<V3DecryptedModel<Table, T>>> + function bulkDecryptModels<T extends Record<string, unknown>>( + input: Array<T>, + ): AuditableDecryptModelOperation<Array<Decrypted<T>>> + function bulkDecryptModels( + input: Array<Record<string, unknown>>, + table?: AnyV3Table, + lockContext?: LockContextInput, + ): AuditableDecryptModelOperation<never> { + const op = client.bulkDecryptModels(input) + const base = lockContext ? op.withLockContext(lockContext) : op + // No table → pass rows through (no date reconstruction). Registered table → + // reconstruct each row. Unregistered table → `undefined` map → + // `unknownTableFailure` on execute. + let mapRows: + | (( + rows: Array<Record<string, unknown>>, + ) => Array<Record<string, unknown>>) + | undefined + if (!table) { + mapRows = passthroughRows + } else { + const reconstruct = reconstructors.get(table.tableName) + mapRows = reconstruct ? (rows) => rows.map(reconstruct) : undefined + } + return new MappedDecryptOperation( + base, + mapRows, + unknownTableFailure, + ) as never + } + + const typed: EncryptionClient<S> = { + encrypt: (plaintext, opts) => + client.encrypt(plaintext as never, opts as never), + encryptQuery, + encryptModel: (input, table) => + client.encryptModel(input as never, table as never) as never, + bulkEncryptModels: (input, table) => + client.bulkEncryptModels(input as never, table as never) as never, + decrypt: (encrypted) => client.decrypt(encrypted), + decryptModel, + bulkDecryptModels, + bulkEncrypt: (plaintexts, opts) => + client.bulkEncrypt(plaintexts as BulkEncryptPayload, opts), + bulkDecrypt: (payloads) => client.bulkDecrypt(payloads), + getEncryptConfig: () => client.getEncryptConfig(), + } + + return typed +} diff --git a/packages/stack/src/encryption/index.ts b/packages/stack/src/encryption/index.ts index 079ac2b28..c4ea6b095 100644 --- a/packages/stack/src/encryption/index.ts +++ b/packages/stack/src/encryption/index.ts @@ -2,19 +2,13 @@ import { type Result, withResult } from '@byteslice/result' import { newClient } from '@cipherstash/protect-ffi' import { validate as uuidValidate } from 'uuid' import type { AnyV3Table } from '@/eql/v3' +import { buildEncryptConfig } from '@/eql/v3' import { type EncryptionError, EncryptionErrorTypes } from '@/errors' // `LockContext` is imported type-only so the TSDoc {@link} references in the // comments below resolve; it is erased at compile time. import type { LockContext } from '@/identity' -import { - buildEncryptConfig, - type EncryptConfig, - encryptConfigSchema, - // Imported type-only for the TSDoc {@link} references in the comments below. - type encryptedColumn, - type encryptedField, - type encryptedTable, -} from '@/schema' +import type { EncryptConfig } from '@/schema' +import { encryptConfigSchema } from '@/schema' import type { AuthStrategy, BuildableTable, @@ -24,16 +18,18 @@ import type { ClientConfig, Encrypted, EncryptedFromBuildableTable, - EncryptionClientConfig, EncryptOptions, EncryptQueryOptions, KeysetIdentifier, Plaintext, ScalarQueryTerm, - V3ClientConfig, } from '@/types' import { hasBuildColumnKeyMap } from '@/types' import { logger } from '@/utils/logger' +// `createEncryptionClient` wraps the internal FFI client with the public +// schema-derived API. The edge is one-way: `./client-v3` names `Encryption` and +// the operation classes type-only, so nothing flows back here at runtime. +import { createEncryptionClient, type EncryptionClient } from './client-v3' import { toFfiKeysetIdentifier } from './helpers' import { isScalarQueryTermArray } from './helpers/type-guards' import { BatchEncryptQueryOperation } from './operations/batch-encrypt-query' @@ -46,15 +42,10 @@ import { DecryptModelOperation } from './operations/decrypt-model' import { EncryptOperation } from './operations/encrypt' import { EncryptModelOperation } from './operations/encrypt-model' import { EncryptQueryOperation } from './operations/encrypt-query' -// `typedClient` wraps the nominal client into the strongly-typed EQL v3 client. -// This is a deliberate circular import with `./v3` (which imports `Encryption` -// from here): both `Encryption` and `typedClient` are hoisted `function` -// declarations, and the only module-eval cross-reference — `EncryptionV3 = -// Encryption` in `./v3` — reads the hoisted `Encryption`, so neither binding is -// observed uninitialised regardless of which module evaluates first. -import { type TypedEncryptionClient, typedClient } from './v3' - -// Re-export the operation classes returned by EncryptionClient methods so they + +export type { EncryptionClient } from './client-v3' + +// Re-export the operation classes returned by `EncryptionClient` methods so they // are part of the public API and appear in the generated reference, allowing // TSDoc {@link} references and method return types to resolve to real pages. export { @@ -71,105 +62,44 @@ export { } export const noClientError = () => - new Error( - 'The Encryption client has not been initialized. Please call init() before using the client.', - ) - -/** - * Resolve the EQL wire version for a client from its schema set. - * - * One FFI client emits exactly one wire format, so the whole schema set must - * agree. EQL v3 tables (from `@cipherstash/stack/v3`) are detected by their - * `buildColumnKeyMap()` marker — v2 tables don't have one: - * - * - every schema is v3 → `3`; - * - no schema is v3 → `undefined`, leaving scalar-only schemas on the FFI's v2 - * default; - * - a mix of the two → throws: the v2 tables target `eql_v2_encrypted` - * columns and the v3 tables target `eql_v3` domains, so no single wire - * format serves both. Split them across two clients. - * - * An explicit `config.eqlVersion` bypasses DETECTION, not validation: mixed - * schemas and legacy v2 SteVec schemas still throw, and so does an explicit `2` - * over an all-v3 set. What survives is `2` over a v2 schema set — minting v2 - * wire during a migration. - * - * @internal exported for unit-test coverage of the detection matrix. - */ -export function resolveEqlVersion( - schemas: readonly BuildableTable[], - explicit?: 2 | 3, -): 2 | 3 | undefined { - const v3Count = schemas.filter(hasBuildColumnKeyMap).length - - if (v3Count > 0 && v3Count < schemas.length) { - throw new Error( - '[encryption]: cannot mix EQL v2 and EQL v3 tables in one client — one client emits exactly one wire format. Create separate clients for the v2 and v3 schemas.', - ) - } - - // cipherstash-client 0.42 removed the EQL v2 SteVec selector envelope. - // protect-ffi 0.30 therefore cannot emit v2 searchable-JSON values at all; - // allowing this legacy schema through would either fail opaquely in the FFI - // or emit v3 data for an eql_v2 column. Fail at setup with a migration steer. - if ( - v3Count === 0 && - schemas.some((schema) => - Object.values(schema.build().columns).some( - (column) => column.indexes?.ste_vec !== undefined, - ), - ) - ) { - throw new Error( - '[encryption]: searchableJson() on the legacy EQL v2 schema is not supported by protect-ffi 0.30. Migrate the column to the EQL v3 types.Json() domain.', - ) - } - - // An explicit 2 over a set that is ENTIRELY v3 is a contradiction, and a - // silent one: the FFI client emits `eql_v2_encrypted` payloads for columns - // whose Postgres domain is `eql_v3_*`, so the write either trips the domain - // CHECK or lands v2 wire wherever the check is looser. - // - // `EncryptionV3` used to prevent this by forcing `eqlVersion: 3` over - // whatever the caller passed — its comment said so explicitly. It is now a - // bare alias of `Encryption`, so a caller upgrading from - // `EncryptionV3({ schemas: [v3Table], config: { eqlVersion: 2 } })` — which - // was silently corrected and worked — now gets a v2-wire client with no - // diagnostic at any layer (#772 review, finding 8). - // - // The escape hatch itself stays: an explicit 2 over a V2 schema set is how - // `integration/shared/v2-decrypt-compat.integration.test.ts` mints the - // fixtures that prove v2 payloads still decrypt. That is the only shape that - // uses it — nothing mints v2 wire from a v3 table. - if (explicit === 2 && schemas.length > 0 && v3Count === schemas.length) { - throw new Error( - "[encryption]: cannot emit EQL v2 wire for a schema set that is entirely EQL v3 — the payloads would not match the columns' eql_v3_* domains. Drop `config.eqlVersion` to emit v3, or build the client from the EQL v2 schema you actually want to write.", - ) - } - - if (explicit !== undefined) { - return explicit + new Error('The Encryption client has not been initialized.') + +/** Reject legacy or structurally invalid schemas at the runtime boundary. */ +function assertV3Schemas(schemas: readonly AnyV3Table[]): void { + for (const table of schemas) { + if (!hasBuildColumnKeyMap(table)) { + const name = + typeof (table as { tableName?: unknown }).tableName === 'string' + ? ` "${(table as { tableName: string }).tableName}"` + : '' + throw new Error( + `[encryption]: schema${name} is not an EQL v3 table. Author schemas with \`encryptedTable\` and \`types.*\` from \`@cipherstash/stack/v3\`.`, + ) + } } - - return v3Count === 0 ? undefined : 3 } -/** The EncryptionClient is the main entry point for interacting with the CipherStash Encryption library. - * It provides methods for encrypting and decrypting individual values, as well as models (objects) and bulk operations. - * - * The client must be initialized using the {@link Encryption} function before it can be used. +/** + * The FFI-backed implementation behind every encryption operation: it provides + * the methods for encrypting and decrypting individual values, as well as models + * (objects) and bulk operations. + * + * It is deliberately NOT exported. The public entry point is {@link Encryption}, + * which initializes one of these and returns it wrapped in an + * {@link EncryptionClient} — the schema-derived surface callers actually hold. + * Nothing outside this module should construct or name this class. */ -export class EncryptionClient { +class NativeEncryptionClient { private client: Client private encryptConfig: EncryptConfig | undefined /** - * Initializes the EncryptionClient with the provided configuration. + * Initializes the NativeEncryptionClient with the provided configuration. * @internal * @param config - The configuration object for initializing the client. - * @returns A promise that resolves to a {@link Result} containing the initialized EncryptionClient or an {@link EncryptionError}. + * @returns A promise that resolves to a {@link Result} containing the initialized NativeEncryptionClient or an {@link EncryptionError}. **/ - async init(config: { + async initialize(config: { encryptConfig: EncryptConfig workspaceCrn?: string accessKey?: string @@ -177,8 +107,7 @@ export class EncryptionClient { clientKey?: string keyset?: KeysetIdentifier authStrategy?: AuthStrategy - eqlVersion?: 2 | 3 - }): Promise<Result<EncryptionClient, EncryptionError>> { + }): Promise<Result<NativeEncryptionClient, EncryptionError>> { return await withResult( async () => { const validated: EncryptConfig = encryptConfigSchema.parse( @@ -199,10 +128,9 @@ export class EncryptionClient { // from the credentials in clientOpts (the clientKey is still used // for encryption). Passing `strategy: undefined` is equivalent to // omitting it, so the default credentials path is unaffected. - // `eqlVersion` selects the wire format `encrypt`/`encryptQuery` - // emit (protect-ffi 0.27+); `eqlVersion: undefined` is likewise - // equivalent to omitting it, leaving the FFI's v2 default — and - // its byte-identical v2 output — untouched. + // Public Stack authoring is EQL v3-only. Pin the FFI explicitly rather + // than relying on its default so dependency upgrades cannot change the + // emitted wire generation. this.client = await newClient({ encryptConfig: validated, clientOpts: { @@ -213,7 +141,7 @@ export class EncryptionClient { keyset: toFfiKeysetIdentifier(config.keyset), }, strategy: config.authStrategy, - eqlVersion: config.eqlVersion, + eqlVersion: 3, }) this.encryptConfig = validated @@ -237,7 +165,7 @@ export class EncryptionClient { * * @example * The following example demonstrates how to encrypt a value using the Encryption client. - * It includes defining an encryption schema with {@link encryptedTable} and {@link encryptedColumn}, + * It includes defining an encryption schema with {@link encryptedTable} and the {@link types} domain factories, * initializing the client with {@link Encryption}, and performing the encryption. * * `encrypt` returns an {@link EncryptOperation} which can be awaited to get a {@link Result} @@ -246,9 +174,9 @@ export class EncryptionClient { * ```typescript * // Define encryption schema * import { Encryption } from "@cipherstash/stack" - * import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema" + * import { encryptedTable, types } from "@cipherstash/stack/v3" * const userSchema = encryptedTable("users", { - * email: encryptedColumn("email"), + * email: types.Text("email"), * }); * * // Initialize Encryption client @@ -298,8 +226,7 @@ export class EncryptionClient { * @see {@link EncryptOptions} * @see {@link Result} * @see {@link encryptedTable} - * @see {@link encryptedColumn} - * @see {@link encryptedField} + * @see {@link types} * @see {@link LockContext} * @see {@link EncryptOperation} */ @@ -320,9 +247,9 @@ export class EncryptionClient { * ```typescript * // Define encryption schema * import { Encryption } from "@cipherstash/stack" - * import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema" + * import { encryptedTable, types } from "@cipherstash/stack/v3" * const userSchema = encryptedTable("users", { - * email: encryptedColumn("email").equality(), + * email: types.TextEq("email"), * }); * * // Initialize Encryption client @@ -472,12 +399,12 @@ export class EncryptionClient { * @example * ```typescript * import { Encryption } from "@cipherstash/stack" - * import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema" + * import { encryptedTable, types } from "@cipherstash/stack/v3" * * type User = { id: string; email: string; createdAt: Date } * * const usersSchema = encryptedTable("users", { - * email: encryptedColumn("email").equality(), + * email: types.TextEq("email"), * }) * * const client = await Encryption({ schemas: [usersSchema] }) @@ -562,12 +489,12 @@ export class EncryptionClient { * @example * ```typescript * import { Encryption } from "@cipherstash/stack" - * import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema" + * import { encryptedTable, types } from "@cipherstash/stack/v3" * * type User = { id: string; email: string } * * const usersSchema = encryptedTable("users", { - * email: encryptedColumn("email"), + * email: types.Text("email"), * }) * * const client = await Encryption({ schemas: [usersSchema] }) @@ -643,17 +570,17 @@ export class EncryptionClient { * your application data. * * @param plaintexts - An array of objects with `plaintext` (and optional `id`) fields. - * @param opts - Options specifying the target column (or nested {@link encryptedField}) and table. See {@link EncryptOptions}. + * @param opts - Options specifying the target column (or nested field) and table. See {@link EncryptOptions}. * @returns A `BulkEncryptOperation` that can be awaited to get a `Result` * containing an array of `{ id?, data: Encrypted }` objects, or an `EncryptionError`. * * @example * ```typescript * import { Encryption } from "@cipherstash/stack" - * import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema" + * import { encryptedTable, types } from "@cipherstash/stack/v3" * * const users = encryptedTable("users", { - * email: encryptedColumn("email"), + * email: types.Text("email"), * }) * const client = await Encryption({ schemas: [users] }) * @@ -751,9 +678,9 @@ export function __resetStrategyDeprecationWarningForTests(): void { * columns to use: * * ```typescript - * import { Encryption, encryptedTable, encryptedColumn } from "@cipherstash/stack" + * import { Encryption, encryptedTable, types } from "@cipherstash/stack/v3" * - * const users = encryptedTable("users", { email: encryptedColumn("email") }) + * const users = encryptedTable("users", { email: types.TextEq("email") }) * const client = await Encryption({ schemas: [users] }) * const result = await client.encrypt("alice@example.com", { column: users.email, table: users }) * ``` @@ -875,8 +802,7 @@ export function __resetStrategyDeprecationWarningForTests(): void { * @param config - Initialization options. Must include `schemas`; optionally include `config` for * credentials and authentication. Logging is configured via the `STASH_STACK_LOG` environment * variable (`debug | info | error`, default: `error`). - * @returns A Promise that resolves to an initialized {@link EncryptionClient} ready for - * {@link EncryptionClient.encrypt}, {@link EncryptionClient.decrypt}, and related operations. + * @returns A Promise that resolves to an initialized {@link EncryptionClient}. * * @throws Throws if `schemas` is empty, or if a keyset `id` is supplied but is not a valid UUID. * Also throws if the client fails to initialize (e.g. invalid credentials or config). @@ -898,8 +824,9 @@ type NonEmptyV3<S extends readonly AnyV3Table[]> = S['length'] extends 0 : S // Overload 1 — v3-typed: an array literal of concrete EQL v3 tables (from -// `@cipherstash/stack/v3`) yields the strongly-typed {@link TypedEncryptionClient}, -// the collapse of the former `EncryptionV3`. The wire format is forced to v3. +// `@cipherstash/stack/v3`) yields the strongly-typed {@link EncryptionClient} +// for that exact tuple — this factory is the collapse of the former +// `EncryptionV3`. The wire format is forced to v3. // // `S` is the ARRAY, not a non-empty tuple. Constraining the type parameter to // `readonly [AnyV3Table, ...AnyV3Table[]]` — which is how the `[]` case was @@ -922,32 +849,22 @@ type NonEmptyV3<S extends readonly AnyV3Table[]> = S['length'] extends 0 // return await Encryption({ schemas }) // TS2769 against `NonEmptyV3<S>` // } // -// That shape compiled before A-4 and is the one the `EncryptionClientFor` docs +// That shape compiled before A-4 and is the one the `EncryptionClient` docs // point generic code at, so it must keep compiling. The overload below carries // the non-emptiness in its CONSTRAINT instead of a conditional, which a generic // `S` can satisfy; the widened one after it then only has to serve arrays that // are concrete at the call site, where the conditional resolves eagerly. export function Encryption< const S extends readonly [AnyV3Table, ...AnyV3Table[]], ->(config: { - schemas: S - config?: V3ClientConfig -}): Promise<TypedEncryptionClient<S>> +>(config: { schemas: S; config?: ClientConfig }): Promise<EncryptionClient<S>> export function Encryption<const S extends readonly AnyV3Table[]>(config: { schemas: NonEmptyV3<S> - config?: V3ClientConfig -}): Promise<TypedEncryptionClient<S>> -// Overload 2 — nominal: loose/dynamic schemas (introspection-derived, e.g. -// stack-supabase) or EQL v2 tables yield the generation-neutral -// {@link EncryptionClient}, which auto-detects its wire version. Declared LAST -// so `Parameters<typeof Encryption>` / `ReturnType<typeof Encryption>` resolve -// to this signature (stack-supabase casts its schemas against it). -export function Encryption( - config: EncryptionClientConfig, -): Promise<EncryptionClient> -export async function Encryption( - config: EncryptionClientConfig, -): Promise<unknown> { + config?: ClientConfig +}): Promise<EncryptionClient<S>> +export async function Encryption(config: { + schemas: readonly AnyV3Table[] + config?: ClientConfig +}): Promise<EncryptionClient<readonly AnyV3Table[]>> { const { schemas, config: clientConfig } = config if (!schemas.length) { @@ -956,6 +873,24 @@ export async function Encryption( ) } + // Reject the PRESENCE of the key, including an explicit `eqlVersion: 3`, so + // the removal is discovered at the source rather than lingering as a no-op + // that looks load-bearing. An explicit `undefined` is the one exception: it + // names no version, and `eqlVersion?: never` cannot reject it at the type + // level without `exactOptionalPropertyTypes` (not enabled here), so throwing + // on it would fail a config the emitted declarations already accepted. + if ( + clientConfig && + Object.hasOwn(clientConfig, 'eqlVersion') && + clientConfig.eqlVersion !== undefined + ) { + throw new Error( + '[encryption]: `config.eqlVersion` has been removed — @cipherstash/stack always authors EQL v3. Remove the field.', + ) + } + + assertV3Schemas(schemas) + if ( clientConfig?.keyset && 'id' in clientConfig.keyset && @@ -975,42 +910,18 @@ export async function Encryption( } const authStrategy = clientConfig?.authStrategy ?? clientConfig?.strategy - const client = new EncryptionClient() + const client = new NativeEncryptionClient() const encryptConfig = buildEncryptConfig(...schemas) - // A schema set of exclusively concrete EQL v3 tables is the typed-client path - // (the collapse of the former `EncryptionV3`). Every other set — EQL v2 tables, - // or the introspection-derived loose tables stack-supabase passes — stays - // nominal. - const isV3Only = schemas.every(hasBuildColumnKeyMap) - - // Resolve the wire format: an explicit `config.eqlVersion` wins (the retained - // migration escape hatch — `2` over a v2 schema set), otherwise it is - // auto-detected (v3 tables → 3, v2 tables → the FFI's v2 default). A mixed - // v2 + v3 set, and an explicit `2` over an all-v3 set, throw inside - // `resolveEqlVersion`. - const eqlVersion = resolveEqlVersion(schemas, clientConfig?.eqlVersion) - - const result = await client.init({ + const result = await client.initialize({ encryptConfig, ...clientConfig, authStrategy, - eqlVersion, }) if (result.failure) { throw new Error(`[encryption]: ${result.failure.message}`) } - // Return the typed client only when the client is genuinely in EQL v3 mode: an - // all-v3 schema set that resolved to the v3 wire format. The `eqlVersion === 3` - // conjunct is now belt-and-braces: `resolveEqlVersion` refuses an explicit `2` - // over an all-v3 set, so an all-v3 set cannot reach here in v2 mode. - if (isV3Only && eqlVersion === 3) { - // biome-ignore lint/plugin: the runtime `isV3Only` guard (every schema has - // buildColumnKeyMap) proves these are AnyV3Table — the compiler can't see it. - const v3Schemas = schemas as unknown as readonly AnyV3Table[] - return typedClient(result.data, ...v3Schemas) - } - return result.data + return createEncryptionClient(result.data, ...schemas) } diff --git a/packages/stack/src/encryption/operations/mapped-decrypt.ts b/packages/stack/src/encryption/operations/mapped-decrypt.ts index e3ee40b50..9393a899a 100644 --- a/packages/stack/src/encryption/operations/mapped-decrypt.ts +++ b/packages/stack/src/encryption/operations/mapped-decrypt.ts @@ -19,16 +19,26 @@ type UnderlyingDecryptOperation<In> = EncryptionOperation<In> & { * awaitable to a `Result<Out, …>`, and chainable with `.audit()` / * `.withLockContext()`. Hides the underlying pre-map type parameter. */ -export interface AuditableDecryptModelOperation<Out> +export interface LockBoundDecryptModelOperation<Out> extends EncryptionOperation<Out> { audit(config: AuditConfig): this - /** - * @throws if the operation already took a lock context positionally — bind it - * once, either positionally or by chaining. - */ +} + +/** + * The unbound form, which additionally offers `.withLockContext()`. + * + * Binding returns the {@link LockBoundDecryptModelOperation} above, which does + * NOT carry the method — a context binds exactly once. The runtime has always + * rejected a second bind (see `MappedDecryptOperation.withLockContext`); until + * this split, the type did not, so `decryptModel(input, table, lc) + * .withLockContext(lc)` compiled and then threw. This mirrors the encrypt path, + * where `EncryptModelOperationWithLockContext` simply lacks the method. + */ +export interface AuditableDecryptModelOperation<Out> + extends LockBoundDecryptModelOperation<Out> { withLockContext( lockContext: LockContextInput, - ): AuditableDecryptModelOperation<Out> + ): LockBoundDecryptModelOperation<Out> } /** diff --git a/packages/stack/src/encryption/v3.ts b/packages/stack/src/encryption/v3.ts index 6e25b29f0..6084578cc 100644 --- a/packages/stack/src/encryption/v3.ts +++ b/packages/stack/src/encryption/v3.ts @@ -1,493 +1,6 @@ -import type { Result } from '@byteslice/result' -import type { - AnyV3Table, - ColumnsOf, - PlaintextForColumn, - QueryableColumnsOf, - QueryTypesForColumn, - V3DecryptedModel, - V3EncryptedModel, - V3ModelInput, -} from '@/eql/v3' -import { DATE_LIKE_CASTS } from '@/eql/v3/columns' -import { reconstructDatePaths } from '@/eql/v3/date-reconstruction' -import { type EncryptionError, EncryptionErrorTypes } from '@/errors' -import type { LockContextInput } from '@/identity' -import type { - BulkDecryptPayload, - BulkEncryptPayload, - Decrypted, - Encrypted, - EncryptedReturnType, - EncryptOptions, - ScalarQueryTerm, -} from '@/types' -import { - type BatchEncryptQueryOperation, - type BulkDecryptOperation, - type BulkEncryptModelsOperation, - type BulkEncryptOperation, - type DecryptOperation, - Encryption, - type EncryptionClient, - type EncryptModelOperation, - type EncryptOperation, - type EncryptQueryOperation, -} from './index' -import { - type AuditableDecryptModelOperation, - MappedDecryptOperation, -} from './operations/mapped-decrypt' - -/** - * A strongly-typed view over an {@link EncryptionClient} for EQL v3 schemas. - * - * Every method derives its types from the concrete `table` / `column` builder - * arguments (which carry their branded types at the call site), so: - * - `encrypt` / `encryptQuery` pin the plaintext to the column's domain type - * (`text → string`, `timestamp → Date`, …); - * - `encryptQuery` additionally constrains `queryType` to the column's - * capabilities and rejects storage-only columns outright; - * - `encryptModel` / `bulkEncryptModels` validate schema-column fields against - * their inferred plaintext type (passthrough fields are untouched) and return - * a precise encrypted model; - * - `decryptModel` / `bulkDecryptModels` return the precise plaintext model, - * reconstructing `Date` values from the encrypt-config `cast_as`. - * - * @typeParam S - the tuple of registered v3 tables; `table` arguments must be a - * member of this tuple. - */ -export interface TypedEncryptionClient<S extends readonly AnyV3Table[]> { - encrypt<Table extends S[number], Col extends ColumnsOf<Table>>( - plaintext: PlaintextForColumn<Col>, - opts: { table: Table; column: Col }, - ): EncryptOperation - - encryptQuery< - Table extends S[number], - Col extends QueryableColumnsOf<Table>, - QT extends QueryTypesForColumn<Col> = QueryTypesForColumn<Col>, - >( - plaintext: PlaintextForColumn<Col>, - opts: { - table: Table - column: Col - queryType?: QT - returnType?: EncryptedReturnType - }, - ): EncryptQueryOperation - - /** - * Batch form: encrypt many query terms in one crossing. Mirrors the nominal - * {@link EncryptionClient} overload — the per-term columns are heterogeneous, - * so the terms are the base {@link ScalarQueryTerm} rather than a per-column - * narrowed type. Consumed by the Drizzle `inArray`/`notInArray` operators. - */ - encryptQuery(terms: readonly ScalarQueryTerm[]): BatchEncryptQueryOperation - - encryptModel<Table extends S[number], T extends Record<string, unknown>>( - input: V3ModelInput<Table, T>, - table: Table, - ): EncryptModelOperation<V3EncryptedModel<Table, T>> - - bulkEncryptModels<Table extends S[number], T extends Record<string, unknown>>( - input: Array<V3ModelInput<Table, T>>, - table: Table, - ): BulkEncryptModelsOperation<V3EncryptedModel<Table, T>> - - /** - * Decrypt a single value. Cannot be strongly typed — a lone ciphertext carries - * no column identity — so it resolves to the FFI plaintext union unchanged. - */ - decrypt(encrypted: Encrypted): DecryptOperation - - /** - * Decrypt a model, returning the precise plaintext shape for `table`. `Date` - * columns are reconstructed from the encrypt-config `cast_as`. - * - * Pass `lockContext` to decrypt identity-bound data — the same context that - * was supplied at encrypt time must be provided here (or chain - * `.withLockContext()` on the returned operation). - * - * Returns a chainable {@link AuditableDecryptModelOperation}: await it for the - * `Result`, or chain `.audit({ metadata })` / `.withLockContext()` first. The - * per-row `Date` reconstruction is applied to the successful result. - */ - decryptModel<Table extends S[number], T extends Record<string, unknown>>( - input: T, - table: Table, - lockContext?: LockContextInput, - ): AuditableDecryptModelOperation<V3DecryptedModel<Table, T>> - - /** - * Table-less form, mirroring the nominal {@link EncryptionClient}: decrypt - * whatever encrypted fields the model carries, with no `Date` reconstruction - * (there is no `cast_as` to reconstruct from) and no precise plaintext shape. - * - * This is the read path for rows that predate this client's schemas — legacy - * **EQL v2** models above all, whose table is not, and cannot be, a member of - * `S`. The runtime has always accepted the one-arg call; without this - * signature the type layer forbids the very compatibility the client - * promises. Prefer the two-arg form whenever the table IS registered. - */ - decryptModel<T extends Record<string, unknown>>( - input: T, - ): AuditableDecryptModelOperation<Decrypted<T>> - - bulkDecryptModels<Table extends S[number], T extends Record<string, unknown>>( - input: Array<T>, - table: Table, - lockContext?: LockContextInput, - ): AuditableDecryptModelOperation<Array<V3DecryptedModel<Table, T>>> - - /** Table-less bulk form — see the one-arg {@link decryptModel} overload. */ - bulkDecryptModels<T extends Record<string, unknown>>( - input: Array<T>, - ): AuditableDecryptModelOperation<Array<Decrypted<T>>> - - // Parity passthroughs — not v3-strengthened, delegated as-is. - bulkEncrypt( - plaintexts: BulkEncryptPayload, - opts: EncryptOptions, - ): BulkEncryptOperation - bulkDecrypt(payloads: BulkDecryptPayload): BulkDecryptOperation - getEncryptConfig(): ReturnType<EncryptionClient['getEncryptConfig']> - - /** - * Re-initialize the underlying client, staying on EQL v3. - * - * @internal Present for runtime parity with {@link EncryptionClient}, not as - * part of the typed authoring surface. `Encryption` picks its return value by - * inspecting the *schemas* while overload resolution inspects the *config*, so - * the two can disagree: a config hoisted into a `ClientConfig`-typed variable - * selects the nominal overload but still yields this client at runtime. Every - * other `EncryptionClient` method already existed here; without `init` that - * mismatch turned into `TypeError: client.init is not a function`. - * - * This is NOT a bare passthrough, because `EncryptionClient.init` would - * otherwise be a way around the guard `Encryption` applies at construction. - * `init` takes its own `eqlVersion`, forwards `undefined` to the FFI (whose - * default is **v2**), and never consults `resolveEqlVersion` — so delegating - * verbatim would let a typed v3 client be re-initialized into v2 wire while - * keeping this v3 surface, the exact contradiction `resolveEqlVersion` - * refuses. The wire version is pinned to `3` and an explicit `2` is rejected. - * - * It also resolves to the TYPED client rather than the underlying nominal one, - * so `client = (await client.init(cfg)).data` — the natural idiom, since - * `EncryptionClient.init` resolves `{ data: this }` — keeps the typed surface - * instead of silently degrading to the nominal one. - */ - init( - config: Omit<Parameters<EncryptionClient['init']>[0], 'eqlVersion'> & { - eqlVersion?: 3 - }, - ): Promise<Result<TypedEncryptionClient<S>, EncryptionError>> -} - -/** - * Build a per-row reconstructor of `Date` values from the table's - * encrypt-config `cast_as`. The FFI returns `JsPlaintext` - * (string/number/boolean/…) with no `Date`, so those columns arrive as their - * serialized form and are rebuilt here. Safe (idempotent) if the FFI ever - * returns `Date` directly: `new Date(date)` is a no-op. - * - * A factory rather than a `(row, table)` function so the table config — - * row-invariant, but non-trivial to build — is derived once per call site, - * not once per row on the bulk path. - * - * NOTE: only date-like casts need per-row reconstruction. `bigint` (int8) - * needs none — protect-ffi 0.28 returns a native JS `bigint` on decrypt - * (and bounds-checks/encodes it on encrypt), so those columns pass through - * unchanged, exactly like `string`/`number`/`boolean`. - */ -function rowReconstructor( - table: AnyV3Table, -): (row: Record<string, unknown>) => Record<string, unknown> { - // The decrypted row is keyed by JS property name, but `cast_as` lives on the - // config keyed by DB name — bridge the two via the table's property→DB map. - const { columns } = table.build() - const propToDb = table.buildColumnKeyMap() - // Only date-like columns need per-row work; resolve them up front. - const dateProperties = Object.entries(propToDb) - .filter(([, dbName]) => { - const castAs = columns[dbName]?.cast_as - // Date-like casts share one source of truth with the type-level - // reconstruction (`PlaintextFromKind`) — see `DATE_LIKE_CASTS`. - return (DATE_LIKE_CASTS as readonly string[]).includes(castAs as string) - }) - .map(([property]) => property) - - return (row) => { - return reconstructDatePaths(row, dateProperties) - } -} - -/** - * Wrap an already-built {@link EncryptionClient} in a {@link TypedEncryptionClient} - * for the given v3 schemas. Zero runtime cost for the encrypt/query paths (the - * underlying operations are returned unchanged); the decrypt-model paths add a - * per-column `Date` reconstruction step. - * - * The `schemas` are captured with a `const` type parameter so array-literal - * widening does not collapse per-table inference. - */ -export function typedClient<const S extends readonly AnyV3Table[]>( - client: EncryptionClient, - ...schemas: S -): TypedEncryptionClient<S> { - // Precompute one row reconstructor per schema table at construction. This runs - // each table's `build()` — which throws on duplicate DB column names — ONCE, - // here, off the Result-returning decrypt path. `decryptModel`/ - // `bulkDecryptModels` therefore never call `build()` (whose throw would surface - // as a promise rejection and break their `Promise<Result<…>>` contract) and no - // longer rebuild the row-invariant config on every call. - // Keyed by `tableName`, not table object identity: `AnyV3Table` is - // structurally typed, so a table re-imported from another module (or rebuilt - // after an HMR reload) satisfies `Table extends S[number]` yet is a different - // object. Identity keying would fail those valid calls. `tableName` is the - // semantic identity the FFI encrypt config and `build()` already key on. - const reconstructors = new Map< - string, - (row: Record<string, unknown>) => Record<string, unknown> - >() - for (const table of schemas) { - reconstructors.set(table.tableName, rowReconstructor(table)) - } - - // A table not among the schemas this client was initialized with has no - // precomputed reconstructor. Return a Result failure rather than building one - // inline, which could throw and reject the Result-shaped decrypt promise. - const unknownTableFailure: { failure: EncryptionError } = { - failure: { - type: EncryptionErrorTypes.DecryptionError, - message: - '[eql/v3]: decryptModel received a table this client was not initialized with — pass a table given to EncryptionV3/typedClient', - }, - } - - // Pass-through maps for a one-arg (nominal-style) decrypt call, where `table` - // is absent: decrypt WITHOUT date reconstruction, exactly as the nominal - // `EncryptionClient` does. This client is now what `Encryption` returns for a - // v3 schema set, so a consumer typed against the nominal overload (e.g. - // stack-supabase's query builder, which casts to it) can call `decryptModel(x)` - // / `bulkDecryptModels(xs)` with no table. Degrade gracefully instead of - // dereferencing `undefined.tableName`. - const passthroughRow = (row: Record<string, unknown>) => row - const passthroughRows = (rows: Array<Record<string, unknown>>) => rows - - // Overloaded so the implementation is checked against BOTH forms directly — - // no whole-value cast. The two public signatures mirror the interface member; - // the hidden implementation signature is broad and forwards to the nominal - // client (which routes to the batch operation when no `opts` are supplied). - // Only the forwarded args are `as never`, exactly as the sibling wrappers - // below: one forwarding body cannot re-derive the nominal client's per-column - // signatures. - function encryptQuery< - Table extends S[number], - Col extends QueryableColumnsOf<Table>, - QT extends QueryTypesForColumn<Col> = QueryTypesForColumn<Col>, - >( - plaintext: PlaintextForColumn<Col>, - opts: { - table: Table - column: Col - queryType?: QT - returnType?: EncryptedReturnType - }, - ): EncryptQueryOperation - function encryptQuery( - terms: readonly ScalarQueryTerm[], - ): BatchEncryptQueryOperation - function encryptQuery( - plaintextOrTerms: unknown, - opts?: unknown, - ): EncryptQueryOperation | BatchEncryptQueryOperation { - return client.encryptQuery(plaintextOrTerms as never, opts as never) - } - - // Overloaded declarations for the same reason as `encryptQuery` above: the - // table-ful and table-less forms have different return types, and a single - // arrow in the object literal cannot present both. - function decryptModel< - Table extends S[number], - T extends Record<string, unknown>, - >( - input: T, - table: Table, - lockContext?: LockContextInput, - ): AuditableDecryptModelOperation<V3DecryptedModel<Table, T>> - function decryptModel<T extends Record<string, unknown>>( - input: T, - ): AuditableDecryptModelOperation<Decrypted<T>> - function decryptModel( - input: Record<string, unknown>, - table?: AnyV3Table, - lockContext?: LockContextInput, - ): AuditableDecryptModelOperation<never> { - // `table` is absent on a nominal-style one-arg call (see `passthroughRow`). - // Given a table: reconstruct dates from its cast_as, or — if it was never - // registered — leave `map` undefined so the mapped op resolves to - // `unknownTableFailure` on execute. - const reconstruct = table - ? reconstructors.get(table.tableName) - : passthroughRow - const op = client.decryptModel(input) - const base = lockContext ? op.withLockContext(lockContext) : op - return new MappedDecryptOperation( - base, - reconstruct, - unknownTableFailure, - ) as never - } - - function bulkDecryptModels< - Table extends S[number], - T extends Record<string, unknown>, - >( - input: Array<T>, - table: Table, - lockContext?: LockContextInput, - ): AuditableDecryptModelOperation<Array<V3DecryptedModel<Table, T>>> - function bulkDecryptModels<T extends Record<string, unknown>>( - input: Array<T>, - ): AuditableDecryptModelOperation<Array<Decrypted<T>>> - function bulkDecryptModels( - input: Array<Record<string, unknown>>, - table?: AnyV3Table, - lockContext?: LockContextInput, - ): AuditableDecryptModelOperation<never> { - const op = client.bulkDecryptModels(input) - const base = lockContext ? op.withLockContext(lockContext) : op - // No table → pass rows through (nominal behaviour). Registered table → - // reconstruct each row. Unregistered table → `undefined` map → - // `unknownTableFailure` on execute. - let mapRows: - | (( - rows: Array<Record<string, unknown>>, - ) => Array<Record<string, unknown>>) - | undefined - if (!table) { - mapRows = passthroughRows - } else { - const reconstruct = reconstructors.get(table.tableName) - mapRows = reconstruct ? (rows) => rows.map(reconstruct) : undefined - } - return new MappedDecryptOperation( - base, - mapRows, - unknownTableFailure, - ) as never - } - - // Annotated rather than `satisfies`, because `init` resolves to `typed` - // itself and a self-referencing initializer has no inferrable type. The - // annotation checks the same shape the `satisfies` did, and the function's - // declared return type is this type anyway, so nothing is widened. - const typed: TypedEncryptionClient<S> = { - encrypt: (plaintext, opts) => - client.encrypt(plaintext as never, opts as never), - encryptQuery, - encryptModel: (input, table) => - client.encryptModel(input as never, table as never) as never, - bulkEncryptModels: (input, table) => - client.bulkEncryptModels(input as never, table as never) as never, - decrypt: (encrypted) => client.decrypt(encrypted), - decryptModel, - bulkDecryptModels, - bulkEncrypt: (plaintexts, opts) => client.bulkEncrypt(plaintexts, opts), - bulkDecrypt: (payloads) => client.bulkDecrypt(payloads), - getEncryptConfig: () => client.getEncryptConfig(), - // See the interface member for why this is not `client.init(config)`: - // `init` bypasses `resolveEqlVersion` and defaults the FFI to v2 wire, so a - // verbatim delegation would reopen the contradiction A-8 closes. Pin v3, - // refuse an explicit 2, and resolve to the typed client so the surface - // survives the round trip. - init: async (config) => { - if (config.eqlVersion !== undefined && config.eqlVersion !== 3) { - return { - failure: { - type: EncryptionErrorTypes.EncryptionError, - message: - "[eql/v3]: cannot re-initialize a typed EQL v3 client at eqlVersion 2 — the payloads would not match the columns' eql_v3_* domains. Build a separate client from the EQL v2 schema you want to write.", - }, - } - } - - const result = await client.init({ ...config, eqlVersion: 3 }) - return result.failure ? result : { data: typed } - }, - } - - return typed -} - -/** - * The client type {@link Encryption} resolves to for the schema tuple `S`. - * - * This is **the** way to name the client — reach for it whenever you need to - * declare a variable, field or return type before the `await` that produces it: - * - * ```typescript - * const users = encryptedTable("users", { email: types.TextSearch("email") }) - * - * let client: EncryptionClientFor<readonly [typeof users]> - * client = await Encryption({ schemas: [users] }) - * ``` - * - * For code that is generic over its schemas — integration adapters that build a - * table per test family, say — name the loose array and keep the typed surface: - * - * ```typescript - * let client: EncryptionClientFor<readonly AnyV3Table[]> - * ``` - * - * **Do not use `Awaited<ReturnType<typeof Encryption>>`.** `Encryption` is - * overloaded, and TypeScript's `ReturnType` reads the LAST overload — the - * nominal one — so that expression yields `EncryptionClient` even for an all-v3 - * schema set, and assigning the real client to it is an error: - * - * ``` - * Type 'TypedEncryptionClient<…>' is missing the following properties - * from type 'EncryptionClient': client, encryptConfig - * ``` - * - * Overload order cannot fix that — whichever signature is last wins, so one of - * the two forms is always mis-resolved, and putting the nominal signature first - * mis-resolves v3 schemas instead (a v3 table structurally satisfies - * `BuildableTable`). A named extraction type is what every comparable library - * does for the same reason: `z.infer`, arktype's `typeof T.infer`, hono's - * `Client<T>`. - */ -export type EncryptionClientFor<S extends readonly unknown[]> = - S extends readonly AnyV3Table[] - ? S['length'] extends 0 - ? EncryptionClient - : TypedEncryptionClient<S> - : EncryptionClient - -/** - * @deprecated Use {@link Encryption} instead — it is now overloaded so an array - * of concrete EQL v3 tables yields the same strongly-typed client this used to. - * `EncryptionV3` is a type-identical alias of `Encryption`, retained for - * backwards compatibility, and will be removed in a future release. - * - * @example - * ```typescript - * import { Encryption, encryptedTable, types } from "@cipherstash/stack/v3" - * - * const users = encryptedTable("users", { email: types.TextSearch("email") }) - * const client = await Encryption({ schemas: [users] }) - * - * await client.encrypt("a@b.com", { table: users, column: users.email }) - * ``` - */ -export const EncryptionV3 = Encryption - -// Single import surface: re-export the v3 `types` namespace + table API + type -// helpers so `@cipherstash/stack/v3` provides everything needed to author and -// use a schema. +// Single import surface (`@cipherstash/stack/v3`): the v3 `types` namespace + +// table API + type helpers, alongside the client factory and its client type, so +// one import provides everything needed to author and use a schema. export * from '@/eql/v3' -// `Encryption` comes along for the same reason — it is the current name for -// what `EncryptionV3` aliases, so authoring a v3 schema and building its -// client should not need a second import specifier. -export { Encryption } +export type { EncryptionClient } from './client-v3' +export { Encryption } from './index' diff --git a/packages/stack/src/eql/v3/table.ts b/packages/stack/src/eql/v3/table.ts index 467249250..ab6496893 100644 --- a/packages/stack/src/eql/v3/table.ts +++ b/packages/stack/src/eql/v3/table.ts @@ -206,7 +206,7 @@ export type QueryableColumnsOf<T extends AnyV3Table> = : never /** - * The accepted input model for {@link import('@/encryption/v3').TypedEncryptionClient.encryptModel}. + * The accepted input model for {@link import('@/encryption/v3').EncryptionClient.encryptModel}. * `T` is inferred from the argument: keys that name a schema column are pinned to * the column's plaintext type (nullable if the field is nullable), so a wrong-typed * field fails assignability; all other keys pass through unchanged. diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 2274c86ed..da1368a72 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -6,7 +6,7 @@ export { encryptedToPgComposite, isEncryptedPayload, } from '@/encryption/helpers' -export { encryptedColumn, encryptedField, encryptedTable } from '@/schema' +export type { EncryptionClient } from '@/encryption/v3' // Re-export auth strategies for convenience. Pass one as `config.authStrategy` // to `Encryption()` to control how ZeroKMS requests are authenticated — notably diff --git a/packages/stack/src/schema/index.ts b/packages/stack/src/schema/index.ts index a53ece0a2..2a8456b57 100644 --- a/packages/stack/src/schema/index.ts +++ b/packages/stack/src/schema/index.ts @@ -1,754 +1,26 @@ -import { z } from 'zod' -import type { BuildableTable, Encrypted } from '@/types' -import { resolveMatchOpts } from './match-defaults' - -// ------------------------ -// Zod schemas -// ------------------------ - -/** - * Allowed cast types for CipherStash schema fields. - * - * **Possible values:** - * - `"bigint"` - * - `"boolean"` - * - `"date"` - * - `"timestamp"` - * - `"number"` - * - `"string"` - * - `"json"` - * - `"text"` - * - * @remarks - * This is a Zod enum used at runtime to validate schema definitions. - * Use {@link CastAs} when typing your own code. - * - * @internal - */ /** - * EQL cast types — the PostgreSQL-aligned types that EQL actually accepts. - * These are stored in the `cast_as` field of the EncryptConfig. - */ -export const eqlCastAsEnum = z - .enum([ - 'text', - 'int', - 'small_int', - 'big_int', - 'real', - 'double', - 'boolean', - 'date', - 'timestamp', - 'jsonb', - ]) - .default('text') - -/** - * SDK-facing data types — developer-friendly aliases accepted by `dataType()`. - * - * `timestamp` is distinct from `date`: `date` is calendar-date only (time-of-day - * truncated to midnight), while `timestamp` preserves the full date+time. v3 - * `timestamp` domains set `cast_as: 'timestamp'` so the FFI keeps the instant. - */ -export const castAsEnum = z - .enum([ - 'bigint', - 'boolean', - 'date', - 'timestamp', - 'number', - 'string', - 'json', - 'text', - ]) - .default('text') - -/** - * Map SDK-facing data types to EQL `cast_as` values. - * - * The SDK accepts developer-friendly types like `'string'` and `'number'`, - * but EQL expects PostgreSQL-aligned types like `'text'` and `'double'`. - */ -export function toEqlCastAs(value: CastAs): EqlCastAs { - switch (value) { - case 'string': - return 'text' - case 'text': - return 'text' - case 'number': - return 'double' - case 'bigint': - return 'big_int' - case 'boolean': - return 'boolean' - case 'date': - return 'date' - case 'timestamp': - return 'timestamp' - case 'json': - return 'jsonb' - } -} - -const tokenFilterSchema = z.object({ - kind: z.literal('downcase'), -}) - -const tokenizerSchema = z - .union([ - z.object({ - kind: z.literal('standard'), - }), - z.object({ - kind: z.literal('ngram'), - token_length: z.number(), - }), - ]) - .default({ kind: 'ngram', token_length: 3 }) - .optional() - -const oreIndexOptsSchema = z.object({}) - -// The OPE (CLLW-OPE, `op` term) ordering index — protect-ffi 0.29+. Emitted by -// the EQL v3 `_ord` domains; the `_ord_ore` domains keep `ore` (block-ORE, -// `ob`). v2 columns never emit it. -const opeIndexOptsSchema = z.object({}) - -const uniqueIndexOptsSchema = z.object({ - token_filters: z.array(tokenFilterSchema).default([]).optional(), -}) - -const matchIndexOptsSchema = z.object({ - tokenizer: tokenizerSchema, - token_filters: z.array(tokenFilterSchema).default([]).optional(), - k: z.number().default(6).optional(), - m: z.number().default(2048).optional(), - include_original: z.boolean().default(false).optional(), -}) - -const arrayIndexModeSchema = z.union([ - z.literal('all'), - z.literal('none'), - z.object({ - item: z.boolean().optional(), - wildcard: z.boolean().optional(), - position: z.boolean().optional(), - }), -]) - -const steVecIndexOptsSchema = z.object({ - prefix: z.string(), - array_index_mode: arrayIndexModeSchema.optional(), - mode: z.enum(['compat', 'standard']).optional(), -}) - -const indexesSchema = z - .object({ - ore: oreIndexOptsSchema.optional(), - ope: opeIndexOptsSchema.optional(), - unique: uniqueIndexOptsSchema.optional(), - match: matchIndexOptsSchema.optional(), - ste_vec: steVecIndexOptsSchema.optional(), - }) - .default({}) - -const columnSchema = z - .object({ - cast_as: castAsEnum, - indexes: indexesSchema, - }) - .default({}) - -const tableSchema = z.record(columnSchema).default({}) - -const tablesSchema = z.record(tableSchema).default({}) - -/** @internal */ -export const encryptConfigSchema = z.object({ - v: z.number(), - tables: tablesSchema, -}) - -// ------------------------ -// Type definitions -// ------------------------ - -/** - * Type-safe alias for {@link castAsEnum} used to specify the *unencrypted* data type of a column or value. - * This is important because once encrypted, all data is stored as binary blobs. - * - * @see {@link castAsEnum} for possible values. - */ -export type CastAs = z.infer<typeof castAsEnum> -export type EqlCastAs = z.infer<typeof eqlCastAsEnum> -export type TokenFilter = z.infer<typeof tokenFilterSchema> -export type MatchIndexOpts = z.infer<typeof matchIndexOptsSchema> -export type SteVecIndexOpts = z.infer<typeof steVecIndexOptsSchema> -export type UniqueIndexOpts = z.infer<typeof uniqueIndexOptsSchema> -export type OreIndexOpts = z.infer<typeof oreIndexOptsSchema> -export type OpeIndexOpts = z.infer<typeof opeIndexOptsSchema> -export type ColumnSchema = z.infer<typeof columnSchema> - -/** - * Shape of table columns: either top-level {@link EncryptedColumn} or nested - * objects whose leaves are {@link EncryptedField}. Used with {@link encryptedTable}. - */ -export type EncryptedTableColumn = { - [key: string]: - | EncryptedColumn - | { - [key: string]: - | EncryptedField - | { - [key: string]: - | EncryptedField - | { - [key: string]: EncryptedField - } - } - } -} -export type EncryptConfig = z.infer<typeof encryptConfigSchema> - -// ------------------------ -// Interface definitions -// ------------------------ - -/** - * Builder for a nested encrypted field (encrypted but not searchable). - * Create with {@link encryptedField}. Use inside nested objects in {@link encryptedTable}; - * supports `.dataType()` for plaintext type. No index methods (equality, orderAndRange, etc.). - * - * @deprecated EQL v2 authoring. The client authors EQL v3; this class backs the - * v2 `encryptedField` builder, retained only for reading/migrating legacy v2 data. - */ -export class EncryptedField { - private valueName: string - private castAsValue: CastAs - - constructor(valueName: string) { - this.valueName = valueName - this.castAsValue = 'string' - } - - /** - * Set or override the plaintext data type for this field. - * - * By default all values are treated as `'string'`. Use this method to specify - * a different type so the encryption layer knows how to encode the plaintext - * before encrypting. - * - * @param castAs - The plaintext data type: `'string'`, `'number'`, `'boolean'`, `'date'`, `'timestamp'`, `'text'`, `'bigint'`, or `'json'`. Use `'timestamp'` (not `'date'`) to preserve time-of-day — `'date'` truncates to midnight. - * @returns This `EncryptedField` instance for method chaining. - * - * @example - * ```typescript - * import { encryptedField } from "@cipherstash/stack/schema" - * - * const age = encryptedField("age").dataType("number") - * ``` - */ - dataType(castAs: CastAs) { - this.castAsValue = castAs - return this - } - - build() { - return { - cast_as: this.castAsValue, - indexes: {}, - } - } - - getName() { - return this.valueName - } -} - -/** - * Builder for an EQL v2 encrypted column. Create with {@link encryptedColumn}. - * - * @deprecated EQL v2 authoring. The client authors EQL v3; this class backs the - * v2 `encryptedColumn` builder, retained only for reading/migrating legacy v2 data. - */ -export class EncryptedColumn { - private columnName: string - private castAsValue: CastAs - private indexesValue: { - ore?: OreIndexOpts - // Never set by the v2 fluent builder (the OPE index is EQL v3-only); - // declared so `build()` stays assignable to the shared indexes shape. - ope?: OpeIndexOpts - unique?: UniqueIndexOpts - match?: Required<MatchIndexOpts> - ste_vec?: SteVecIndexOpts - } = {} - - constructor(columnName: string) { - this.columnName = columnName - this.castAsValue = 'string' - } - - /** - * Set or override the plaintext data type for this column. - * - * By default all columns are treated as `'string'`. Use this method to specify - * a different type so the encryption layer knows how to encode the plaintext - * before encrypting. - * - * @param castAs - The plaintext data type: `'string'`, `'number'`, `'boolean'`, `'date'`, `'timestamp'`, `'text'`, `'bigint'`, or `'json'`. Use `'timestamp'` (not `'date'`) to preserve time-of-day — `'date'` truncates to midnight. - * @returns This `EncryptedColumn` instance for method chaining. - * - * @example - * ```typescript - * import { encryptedColumn } from "@cipherstash/stack/schema" - * - * const dateOfBirth = encryptedColumn("date_of_birth").dataType("date") - * ``` - */ - dataType(castAs: CastAs) { - this.castAsValue = castAs - return this - } - - /** - * Enable Order-Revealing Encryption (ORE) indexing on this column. - * - * ORE allows sorting, comparison, and range queries on encrypted data. - * Use with `encryptQuery` and `queryType: 'orderAndRange'`. - * - * @returns This `EncryptedColumn` instance for method chaining. - * - * @example - * ```typescript - * import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema" - * - * const users = encryptedTable("users", { - * email: encryptedColumn("email").orderAndRange(), - * }) - * ``` - */ - orderAndRange() { - this.indexesValue.ore = {} - return this - } - - /** - * Enable an exact-match (unique) index on this column. - * - * Allows equality queries on encrypted data. Use with `encryptQuery` - * and `queryType: 'equality'`. - * - * @param tokenFilters - Optional array of token filters (e.g. `[{ kind: 'downcase' }]`). - * When omitted, no token filters are applied. - * @returns This `EncryptedColumn` instance for method chaining. - * - * @example - * ```typescript - * import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema" - * - * const users = encryptedTable("users", { - * email: encryptedColumn("email").equality(), - * }) - * ``` - */ - equality(tokenFilters?: TokenFilter[]) { - this.indexesValue.unique = { - token_filters: tokenFilters ?? [], - } - return this - } - - /** - * Enable a full-text / fuzzy search (match) index on this column. - * - * Uses n-gram tokenization by default for substring and fuzzy matching. - * Use with `encryptQuery` and `queryType: 'freeTextSearch'`. - * - * @param opts - Optional match index configuration. Defaults to 3-character ngram - * tokenization with a downcase filter, `k=6`, `m=2048`, and `include_original=true`. - * @returns This `EncryptedColumn` instance for method chaining. - * - * @example - * ```typescript - * import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema" - * - * const users = encryptedTable("users", { - * email: encryptedColumn("email").freeTextSearch(), - * }) - * - * // With custom options - * const posts = encryptedTable("posts", { - * body: encryptedColumn("body").freeTextSearch({ - * tokenizer: { kind: "ngram", token_length: 4 }, - * k: 8, - * m: 4096, - * }), - * }) - * ``` - */ - freeTextSearch(opts?: MatchIndexOpts) { - // Shared merge+clone (schema/match-defaults) — one source of truth with the - // EQL v3 domain builders. `resolveMatchOpts` deep-clones, so a caller - // mutating their own `opts` (or its nested tokenizer/token_filters) after - // this call cannot leak into the stored schema. - this.indexesValue.match = resolveMatchOpts(opts) - return this - } - - /** - * Configure this column for searchable encrypted JSON (STE-Vec). - * - * Enables encrypted JSONPath selector queries (e.g. `'$.user.email'`) and - * containment queries (e.g. `{ role: 'admin' }`). Automatically sets the - * data type to `'json'`. - * - * When used with `encryptQuery`, the query operation is auto-inferred from - * the plaintext type: strings become selector queries, objects/arrays become - * containment queries. - * - * @deprecated Legacy EQL v2 searchable JSON cannot be emitted by - * protect-ffi 0.30. Migrate the column to the EQL v3 `types.Json()` domain; - * client initialization rejects schemas that still use this method. - * - * @returns This `EncryptedColumn` instance for method chaining. - * - * @example - * ```typescript - * import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema" - * - * const documents = encryptedTable("documents", { - * metadata: encryptedColumn("metadata").searchableJson(), - * }) - * ``` - */ - searchableJson() { - this.castAsValue = 'json' - // `mode: 'standard'` pins the per-entry ordering term to CLLW-ORE (`oc`), - // the only encoding the eql_v2 SQL compares. protect-ffi 0.29 flipped the - // library default to `compat` (CLLW-OPE, `op`) for EQL v3; without the pin - // every v2 containment query silently matches nothing — and existing v2 - // rows encrypted under `standard` are not cross-comparable with `compat` - // anyway, so this also keeps the v2 wire format byte-stable. - this.indexesValue.ste_vec = { - prefix: 'enabled', - array_index_mode: 'all', - mode: 'standard', - } - return this - } - - build() { - return { - cast_as: this.castAsValue, - indexes: this.indexesValue, - } - } - - getName() { - return this.columnName - } -} - -interface TableDefinition { - tableName: string - columns: Record<string, ColumnSchema> -} - -export class EncryptedTable<T extends EncryptedTableColumn> { - /** @internal Type-level brand so TypeScript can infer `T` from `EncryptedTable<T>`. */ - declare readonly _columnType: T - - constructor( - public readonly tableName: string, - public readonly columnBuilders: T, - ) {} - - /** - * Compile this table schema into a `TableDefinition` used internally by the encryption client. - * - * Iterates over all column builders, calls `.build()` on each, and assembles - * the final `{ tableName, columns }` structure. For `searchableJson()` columns, - * the STE-Vec prefix is automatically set to `"<tableName>/<columnName>"`. - * - * @returns A `TableDefinition` containing the table name and built column configs. - * - * @example - * ```typescript - * const users = encryptedTable("users", { - * email: encryptedColumn("email").equality(), - * }) - * - * const definition = users.build() - * // { tableName: "users", columns: { email: { cast_as: "string", indexes: { unique: ... } } } } - * ``` - */ - build(): TableDefinition { - const builtColumns: Record<string, ColumnSchema> = {} - - const processColumn = ( - builder: - | EncryptedColumn - | Record< - string, - | EncryptedField - | Record< - string, - | EncryptedField - | Record< - string, - EncryptedField | Record<string, EncryptedField> - > - > - >, - colName: string, - ) => { - if (builder instanceof EncryptedColumn) { - const builtColumn = builder.build() - - // Hanlde building the ste_vec index for JSON columns so users don't have to pass the prefix. - if ( - builtColumn.cast_as === 'json' && - builtColumn.indexes.ste_vec?.prefix === 'enabled' - ) { - builtColumns[colName] = { - ...builtColumn, - indexes: { - ...builtColumn.indexes, - ste_vec: { - ...builtColumn.indexes.ste_vec, - prefix: `${this.tableName}/${colName}`, - }, - }, - } - } else { - builtColumns[colName] = builtColumn - } - } else { - for (const [key, value] of Object.entries(builder)) { - if (value instanceof EncryptedField) { - builtColumns[value.getName()] = value.build() - } else { - processColumn(value, key) - } - } - } - } - - for (const [colName, builder] of Object.entries(this.columnBuilders)) { - processColumn(builder, colName) - } - - return { - tableName: this.tableName, - columns: builtColumns, - } - } -} - -// ------------------------ -// Schema type inference helpers -// ------------------------ - -/** - * Infer the plaintext (decrypted) type from a EncryptedTable schema. - * - * @example - * ```typescript - * const users = encryptedTable("users", { - * email: encryptedColumn("email").equality(), - * name: encryptedColumn("name"), - * }) - * - * type UserPlaintext = InferPlaintext<typeof users> - * // => { email: string; name: string } - * ``` - */ -export type InferPlaintext<T extends EncryptedTable<any>> = - T extends EncryptedTable<infer C> - ? { - [K in keyof C as C[K] extends EncryptedColumn | EncryptedField - ? K - : never]: string - } - : never - -/** - * Infer the encrypted type from a EncryptedTable schema. - * - * @example - * ```typescript - * const users = encryptedTable("users", { - * email: encryptedColumn("email").equality(), - * }) - * - * type UserEncrypted = InferEncrypted<typeof users> - * // => { email: Encrypted } - * ``` - */ -export type InferEncrypted<T extends EncryptedTable<any>> = - T extends EncryptedTable<infer C> - ? { - [K in keyof C as C[K] extends EncryptedColumn | EncryptedField - ? K - : never]: Encrypted - } - : never - -// ------------------------ -// User facing functions -// ------------------------ - -/** - * Define an encrypted table schema. - * - * Creates a `EncryptedTable` that maps a database table name to a set of encrypted - * column definitions. Pass the resulting object to `Encryption({ schemas: [...] })` - * when initializing the client. - * - * The returned object is also a proxy that exposes each column builder directly, - * so you can reference columns as `users.email` when calling `encrypt`, `decrypt`, - * and `encryptQuery`. - * - * @deprecated EQL v2 authoring. The client authors EQL v3 — build tables with - * `encryptedTable` + the `types.*` factories from `@cipherstash/stack/v3`. This - * v2 builder remains only for reading/migrating legacy v2 data and will be - * removed once the v2 adapters are gone. - * - * @param tableName - The name of the database table this schema represents. - * @param columns - An object whose keys are logical column names and values are - * {@link EncryptedColumn} from {@link encryptedColumn}, or nested objects whose - * leaves are {@link EncryptedField} from {@link encryptedField}. - * @returns A `EncryptedTable<T> & T` that can be used as both a schema definition - * and a column accessor. - * - * @example - * ```typescript - * import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema" - * - * const users = encryptedTable("users", { - * email: encryptedColumn("email").equality().freeTextSearch(), - * address: encryptedColumn("address"), - * }) - * - * // Use as schema - * const client = await Encryption({ schemas: [users] }) - * - * // Use as column accessor - * await client.encrypt("hello@example.com", { column: users.email, table: users }) - * ``` - */ -export function encryptedTable<T extends EncryptedTableColumn>( - tableName: string, - columns: T, -): EncryptedTable<T> & T { - const tableBuilder = new EncryptedTable( - tableName, - columns, - ) as EncryptedTable<T> & T - - for (const [colName, colBuilder] of Object.entries(columns)) { - ;(tableBuilder as EncryptedTableColumn)[colName] = colBuilder - } - - return tableBuilder -} - -/** - * Define an encrypted column within a table schema. - * - * Creates a `EncryptedColumn` builder for the given column name. Chain index - * methods (`.equality()`, `.freeTextSearch()`, `.orderAndRange()`, - * `.searchableJson()`) and/or `.dataType()` to configure searchable encryption - * and the plaintext data type. - * - * @deprecated EQL v2 authoring. The client authors EQL v3 — define columns with - * the `types.*` concrete-domain factories from `@cipherstash/stack/v3`. This v2 - * builder remains only for reading/migrating legacy v2 data and will be removed - * once the v2 adapters are gone. - * - * @param columnName - The name of the database column to encrypt. - * @returns A new `EncryptedColumn` builder. - * - * @example - * ```typescript - * import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema" - * - * const users = encryptedTable("users", { - * email: encryptedColumn("email").equality().freeTextSearch().orderAndRange(), - * }) - * ``` - */ -export function encryptedColumn(columnName: string) { - return new EncryptedColumn(columnName) -} - -/** - * Define an encrypted field for use in nested or structured schemas. - * - * `encryptedField` is similar to {@link encryptedColumn} but creates an {@link EncryptedField} - * for nested fields that are encrypted but not searchable (no indexes). Use `.dataType()` - * to specify the plaintext type. - * - * @deprecated EQL v2 authoring. The client authors EQL v3 — model nested fields - * with a dotted v3 column path (`'profile.ssn': types.TextEq(...)`) from - * `@cipherstash/stack/v3`. This v2 builder remains only for reading/migrating - * legacy v2 data and will be removed once the v2 adapters are gone. - * - * @param valueName - The name of the value field. - * @returns A new `EncryptedField` builder. - * - * @example - * ```typescript - * import { encryptedTable, encryptedField } from "@cipherstash/stack/schema" - * - * const orders = encryptedTable("orders", { - * details: { - * amount: encryptedField("amount").dataType("number"), - * currency: encryptedField("currency"), - * }, - * }) - * ``` - */ -export function encryptedField(valueName: string) { - return new EncryptedField(valueName) -} - -/** - * Build an encrypt config from a list of encrypted tables. - * - * @param protectTables - The list of encrypted tables to build the config from. - * @returns An encrypt config object. - * - * @example - * ```typescript - * import { buildEncryptConfig } from "@cipherstash/stack/schema" - * - * const users = encryptedTable("users", { - * email: encryptedColumn("email").equality(), - * }) - * - * const orders = encryptedTable("orders", { - * amount: encryptedColumn("amount").dataType("number"), - * }) - * - * const config = buildEncryptConfig(users, orders) - * console.log(config) - * ``` - */ -export function buildEncryptConfig( - ...protectTables: Array<BuildableTable> -): EncryptConfig { - const config: EncryptConfig = { - v: 1, - tables: {}, - } - - for (const tb of protectTables) { - const tableDef = tb.build() - config.tables[tableDef.tableName] = tableDef.columns - } - - return config -} + * Low-level encryption configuration primitives. + * + * Schema authoring is EQL v3-only; import `encryptedTable` and `types` from + * `@cipherstash/stack/v3`. This subpath remains for packages that inspect or + * validate the generated protect-ffi configuration. + */ + +export type { + CastAs, + ColumnSchema, + EncryptConfig, + EqlCastAs, + MatchIndexOpts, + OpeIndexOpts, + OreIndexOpts, + SteVecIndexOpts, + TokenFilter, + UniqueIndexOpts, +} from './internal' +export { + castAsEnum, + encryptConfigSchema, + eqlCastAsEnum, + toEqlCastAs, +} from './internal' diff --git a/packages/stack/src/schema/internal.ts b/packages/stack/src/schema/internal.ts new file mode 100644 index 000000000..7b71d6c98 --- /dev/null +++ b/packages/stack/src/schema/internal.ts @@ -0,0 +1,765 @@ +import { z } from 'zod' +import type { BuildableTable, Encrypted } from '@/types' +import { resolveMatchOpts } from './match-defaults' + +// ------------------------ +// Zod schemas +// ------------------------ + +/** + * Allowed cast types for CipherStash schema fields. + * + * **Possible values:** + * - `"bigint"` + * - `"boolean"` + * - `"date"` + * - `"timestamp"` + * - `"number"` + * - `"string"` + * - `"json"` + * - `"text"` + * + * @remarks + * This is a Zod enum used at runtime to validate schema definitions. + * Use {@link CastAs} when typing your own code. + * + * @internal + */ +/** + * EQL cast types — the PostgreSQL-aligned types that EQL actually accepts. + * These are stored in the `cast_as` field of the EncryptConfig. + */ +export const eqlCastAsEnum = z + .enum([ + 'text', + 'int', + 'small_int', + 'big_int', + 'real', + 'double', + 'boolean', + 'date', + 'timestamp', + 'jsonb', + ]) + .default('text') + +/** + * SDK-facing data types — developer-friendly aliases accepted by `dataType()`. + * + * `timestamp` is distinct from `date`: `date` is calendar-date only (time-of-day + * truncated to midnight), while `timestamp` preserves the full date+time. v3 + * `timestamp` domains set `cast_as: 'timestamp'` so the FFI keeps the instant. + */ +export const castAsEnum = z + .enum([ + 'bigint', + 'boolean', + 'date', + 'timestamp', + 'number', + 'string', + 'json', + 'text', + ]) + .default('text') + +/** + * Map SDK-facing data types to EQL `cast_as` values. + * + * The SDK accepts developer-friendly types like `'string'` and `'number'`, + * but EQL expects PostgreSQL-aligned types like `'text'` and `'double'`. + */ +export function toEqlCastAs(value: CastAs): EqlCastAs { + switch (value) { + case 'string': + return 'text' + case 'text': + return 'text' + case 'number': + return 'double' + case 'bigint': + return 'big_int' + case 'boolean': + return 'boolean' + case 'date': + return 'date' + case 'timestamp': + return 'timestamp' + case 'json': + return 'jsonb' + } +} + +const tokenFilterSchema = z.object({ + kind: z.literal('downcase'), +}) + +const tokenizerSchema = z + .union([ + z.object({ + kind: z.literal('standard'), + }), + z.object({ + kind: z.literal('ngram'), + token_length: z.number(), + }), + ]) + .default({ kind: 'ngram', token_length: 3 }) + .optional() + +const oreIndexOptsSchema = z.object({}) + +// The OPE (CLLW-OPE, `op` term) ordering index — protect-ffi 0.29+. Emitted by +// the EQL v3 `_ord` domains; the `_ord_ore` domains keep `ore` (block-ORE, +// `ob`). v2 columns never emit it. +const opeIndexOptsSchema = z.object({}) + +const uniqueIndexOptsSchema = z.object({ + token_filters: z.array(tokenFilterSchema).default([]).optional(), +}) + +const matchIndexOptsSchema = z.object({ + tokenizer: tokenizerSchema, + token_filters: z.array(tokenFilterSchema).default([]).optional(), + k: z.number().default(6).optional(), + m: z.number().default(2048).optional(), + include_original: z.boolean().default(false).optional(), +}) + +const arrayIndexModeSchema = z.union([ + z.literal('all'), + z.literal('none'), + z.object({ + item: z.boolean().optional(), + wildcard: z.boolean().optional(), + position: z.boolean().optional(), + }), +]) + +const steVecIndexOptsSchema = z.object({ + prefix: z.string(), + array_index_mode: arrayIndexModeSchema.optional(), + mode: z.enum(['compat', 'standard']).optional(), +}) + +const indexesSchema = z + .object({ + ore: oreIndexOptsSchema.optional(), + ope: opeIndexOptsSchema.optional(), + unique: uniqueIndexOptsSchema.optional(), + match: matchIndexOptsSchema.optional(), + ste_vec: steVecIndexOptsSchema.optional(), + }) + .default({}) + +const columnSchema = z + .object({ + cast_as: castAsEnum, + indexes: indexesSchema, + }) + .default({}) + +const tableSchema = z.record(columnSchema).default({}) + +const tablesSchema = z.record(tableSchema).default({}) + +/** @internal */ +export const encryptConfigSchema = z.object({ + v: z.number(), + tables: tablesSchema, +}) + +// ------------------------ +// Type definitions +// ------------------------ + +/** + * Type-safe alias for {@link castAsEnum} used to specify the *unencrypted* data type of a column or value. + * This is important because once encrypted, all data is stored as binary blobs. + * + * @see {@link castAsEnum} for possible values. + */ +export type CastAs = z.infer<typeof castAsEnum> +export type EqlCastAs = z.infer<typeof eqlCastAsEnum> +export type TokenFilter = z.infer<typeof tokenFilterSchema> +export type MatchIndexOpts = z.infer<typeof matchIndexOptsSchema> +export type SteVecIndexOpts = z.infer<typeof steVecIndexOptsSchema> +export type UniqueIndexOpts = z.infer<typeof uniqueIndexOptsSchema> +export type OreIndexOpts = z.infer<typeof oreIndexOptsSchema> +export type OpeIndexOpts = z.infer<typeof opeIndexOptsSchema> +export type ColumnSchema = z.infer<typeof columnSchema> + +/** + * Shape of table columns: either top-level {@link EncryptedColumn} or nested + * objects whose leaves are {@link EncryptedField}. Used with {@link encryptedTable}. + */ +export type EncryptedTableColumn = { + [key: string]: + | EncryptedColumn + | { + [key: string]: + | EncryptedField + | { + [key: string]: + | EncryptedField + | { + [key: string]: EncryptedField + } + } + } +} +export type EncryptConfig = z.infer<typeof encryptConfigSchema> + +// ------------------------ +// Interface definitions +// ------------------------ + +/** + * Builder for a nested encrypted field (encrypted but not searchable). + * Create with {@link encryptedField}. Use inside nested objects in {@link encryptedTable}; + * supports `.dataType()` for plaintext type. No index methods (equality, orderAndRange, etc.). + * + * @deprecated EQL v2 authoring. The client authors EQL v3; this class backs the + * v2 `encryptedField` builder, retained only for reading/migrating legacy v2 data. + */ +export class EncryptedField { + private valueName: string + private castAsValue: CastAs + + constructor(valueName: string) { + this.valueName = valueName + this.castAsValue = 'string' + } + + /** + * Set or override the plaintext data type for this field. + * + * By default all values are treated as `'string'`. Use this method to specify + * a different type so the encryption layer knows how to encode the plaintext + * before encrypting. + * + * @param castAs - The plaintext data type: `'string'`, `'number'`, `'boolean'`, `'date'`, `'timestamp'`, `'text'`, `'bigint'`, or `'json'`. Use `'timestamp'` (not `'date'`) to preserve time-of-day — `'date'` truncates to midnight. + * @returns This `EncryptedField` instance for method chaining. + * + * @internal EQL v2 authoring. Not exported from any public subpath — retained + * only for reading/migrating legacy v2 data. + * + * @example + * ```typescript + * const age = encryptedField("age").dataType("number") + * ``` + */ + dataType(castAs: CastAs) { + this.castAsValue = castAs + return this + } + + build() { + return { + cast_as: this.castAsValue, + indexes: {}, + } + } + + getName() { + return this.valueName + } +} + +/** + * Builder for an EQL v2 encrypted column. Create with {@link encryptedColumn}. + * + * @deprecated EQL v2 authoring. The client authors EQL v3; this class backs the + * v2 `encryptedColumn` builder, retained only for reading/migrating legacy v2 data. + */ +export class EncryptedColumn { + private columnName: string + private castAsValue: CastAs + private indexesValue: { + ore?: OreIndexOpts + // Never set by the v2 fluent builder (the OPE index is EQL v3-only); + // declared so `build()` stays assignable to the shared indexes shape. + ope?: OpeIndexOpts + unique?: UniqueIndexOpts + match?: Required<MatchIndexOpts> + ste_vec?: SteVecIndexOpts + } = {} + + constructor(columnName: string) { + this.columnName = columnName + this.castAsValue = 'string' + } + + /** + * Set or override the plaintext data type for this column. + * + * By default all columns are treated as `'string'`. Use this method to specify + * a different type so the encryption layer knows how to encode the plaintext + * before encrypting. + * + * @param castAs - The plaintext data type: `'string'`, `'number'`, `'boolean'`, `'date'`, `'timestamp'`, `'text'`, `'bigint'`, or `'json'`. Use `'timestamp'` (not `'date'`) to preserve time-of-day — `'date'` truncates to midnight. + * @returns This `EncryptedColumn` instance for method chaining. + * + * @internal EQL v2 authoring. Not exported from any public subpath — retained + * only for reading/migrating legacy v2 data. + * + * @example + * ```typescript + * const dateOfBirth = encryptedColumn("date_of_birth").dataType("date") + * ``` + */ + dataType(castAs: CastAs) { + this.castAsValue = castAs + return this + } + + /** + * Enable Order-Revealing Encryption (ORE) indexing on this column. + * + * ORE allows sorting, comparison, and range queries on encrypted data. + * Use with `encryptQuery` and `queryType: 'orderAndRange'`. + * + * @returns This `EncryptedColumn` instance for method chaining. + * + * @internal EQL v2 authoring. Not exported from any public subpath — retained + * only for reading/migrating legacy v2 data. + * + * @example + * ```typescript + * const users = encryptedTable("users", { + * email: encryptedColumn("email").orderAndRange(), + * }) + * ``` + */ + orderAndRange() { + this.indexesValue.ore = {} + return this + } + + /** + * Enable an exact-match (unique) index on this column. + * + * Allows equality queries on encrypted data. Use with `encryptQuery` + * and `queryType: 'equality'`. + * + * @param tokenFilters - Optional array of token filters (e.g. `[{ kind: 'downcase' }]`). + * When omitted, no token filters are applied. + * @returns This `EncryptedColumn` instance for method chaining. + * + * @internal EQL v2 authoring. Not exported from any public subpath — retained + * only for reading/migrating legacy v2 data. + * + * @example + * ```typescript + * const users = encryptedTable("users", { + * email: encryptedColumn("email").equality(), + * }) + * ``` + */ + equality(tokenFilters?: TokenFilter[]) { + this.indexesValue.unique = { + token_filters: tokenFilters ?? [], + } + return this + } + + /** + * Enable a full-text / fuzzy search (match) index on this column. + * + * Uses n-gram tokenization by default for substring and fuzzy matching. + * Use with `encryptQuery` and `queryType: 'freeTextSearch'`. + * + * @param opts - Optional match index configuration. Defaults to 3-character ngram + * tokenization with a downcase filter, `k=6`, `m=2048`, and `include_original=true`. + * @returns This `EncryptedColumn` instance for method chaining. + * + * @internal EQL v2 authoring. Not exported from any public subpath — retained + * only for reading/migrating legacy v2 data. + * + * @example + * ```typescript + * const users = encryptedTable("users", { + * email: encryptedColumn("email").freeTextSearch(), + * }) + * + * // With custom options + * const posts = encryptedTable("posts", { + * body: encryptedColumn("body").freeTextSearch({ + * tokenizer: { kind: "ngram", token_length: 4 }, + * k: 8, + * m: 4096, + * }), + * }) + * ``` + */ + freeTextSearch(opts?: MatchIndexOpts) { + // Shared merge+clone (schema/match-defaults) — one source of truth with the + // EQL v3 domain builders. `resolveMatchOpts` deep-clones, so a caller + // mutating their own `opts` (or its nested tokenizer/token_filters) after + // this call cannot leak into the stored schema. + this.indexesValue.match = resolveMatchOpts(opts) + return this + } + + /** + * Configure this column for searchable encrypted JSON (STE-Vec). + * + * Enables encrypted JSONPath selector queries (e.g. `'$.user.email'`) and + * containment queries (e.g. `{ role: 'admin' }`). Automatically sets the + * data type to `'json'`. + * + * When used with `encryptQuery`, the query operation is auto-inferred from + * the plaintext type: strings become selector queries, objects/arrays become + * containment queries. + * + * @deprecated Legacy EQL v2 searchable JSON cannot be emitted by + * protect-ffi 0.30. Migrate the column to the EQL v3 `types.Json()` domain; + * client initialization rejects schemas that still use this method. + * + * @returns This `EncryptedColumn` instance for method chaining. + * + * @internal EQL v2 authoring. Not exported from any public subpath — retained + * only for reading/migrating legacy v2 data. + * + * @example + * ```typescript + * const documents = encryptedTable("documents", { + * metadata: encryptedColumn("metadata").searchableJson(), + * }) + * ``` + */ + searchableJson() { + this.castAsValue = 'json' + // `mode: 'standard'` pins the per-entry ordering term to CLLW-ORE (`oc`), + // the only encoding the eql_v2 SQL compares. protect-ffi 0.29 flipped the + // library default to `compat` (CLLW-OPE, `op`) for EQL v3; without the pin + // every v2 containment query silently matches nothing — and existing v2 + // rows encrypted under `standard` are not cross-comparable with `compat` + // anyway, so this also keeps the v2 wire format byte-stable. + this.indexesValue.ste_vec = { + prefix: 'enabled', + array_index_mode: 'all', + mode: 'standard', + } + return this + } + + build() { + return { + cast_as: this.castAsValue, + indexes: this.indexesValue, + } + } + + getName() { + return this.columnName + } +} + +interface TableDefinition { + tableName: string + columns: Record<string, ColumnSchema> +} + +export class EncryptedTable<T extends EncryptedTableColumn> { + /** @internal Type-level brand so TypeScript can infer `T` from `EncryptedTable<T>`. */ + declare readonly _columnType: T + + constructor( + public readonly tableName: string, + public readonly columnBuilders: T, + ) {} + + /** + * Compile this table schema into a `TableDefinition` used internally by the encryption client. + * + * Iterates over all column builders, calls `.build()` on each, and assembles + * the final `{ tableName, columns }` structure. For `searchableJson()` columns, + * the STE-Vec prefix is automatically set to `"<tableName>/<columnName>"`. + * + * @returns A `TableDefinition` containing the table name and built column configs. + * + * @example + * ```typescript + * const users = encryptedTable("users", { + * email: encryptedColumn("email").equality(), + * }) + * + * const definition = users.build() + * // { tableName: "users", columns: { email: { cast_as: "string", indexes: { unique: ... } } } } + * ``` + */ + build(): TableDefinition { + const builtColumns: Record<string, ColumnSchema> = {} + + const processColumn = ( + builder: + | EncryptedColumn + | Record< + string, + | EncryptedField + | Record< + string, + | EncryptedField + | Record< + string, + EncryptedField | Record<string, EncryptedField> + > + > + >, + colName: string, + ) => { + if (builder instanceof EncryptedColumn) { + const builtColumn = builder.build() + + // Hanlde building the ste_vec index for JSON columns so users don't have to pass the prefix. + if ( + builtColumn.cast_as === 'json' && + builtColumn.indexes.ste_vec?.prefix === 'enabled' + ) { + builtColumns[colName] = { + ...builtColumn, + indexes: { + ...builtColumn.indexes, + ste_vec: { + ...builtColumn.indexes.ste_vec, + prefix: `${this.tableName}/${colName}`, + }, + }, + } + } else { + builtColumns[colName] = builtColumn + } + } else { + for (const [key, value] of Object.entries(builder)) { + if (value instanceof EncryptedField) { + builtColumns[value.getName()] = value.build() + } else { + processColumn(value, key) + } + } + } + } + + for (const [colName, builder] of Object.entries(this.columnBuilders)) { + processColumn(builder, colName) + } + + return { + tableName: this.tableName, + columns: builtColumns, + } + } +} + +// ------------------------ +// Schema type inference helpers +// ------------------------ + +/** + * Infer the plaintext (decrypted) type from a EncryptedTable schema. + * + * @example + * ```typescript + * const users = encryptedTable("users", { + * email: encryptedColumn("email").equality(), + * name: encryptedColumn("name"), + * }) + * + * type UserPlaintext = InferPlaintext<typeof users> + * // => { email: string; name: string } + * ``` + */ +export type InferPlaintext<T extends EncryptedTable<any>> = + T extends EncryptedTable<infer C> + ? { + [K in keyof C as C[K] extends EncryptedColumn | EncryptedField + ? K + : never]: string + } + : never + +/** + * Infer the encrypted type from a EncryptedTable schema. + * + * @example + * ```typescript + * const users = encryptedTable("users", { + * email: encryptedColumn("email").equality(), + * }) + * + * type UserEncrypted = InferEncrypted<typeof users> + * // => { email: Encrypted } + * ``` + */ +export type InferEncrypted<T extends EncryptedTable<any>> = + T extends EncryptedTable<infer C> + ? { + [K in keyof C as C[K] extends EncryptedColumn | EncryptedField + ? K + : never]: Encrypted + } + : never + +// ------------------------ +// User facing functions +// ------------------------ + +/** + * Define an encrypted table schema. + * + * Creates a `EncryptedTable` that maps a database table name to a set of encrypted + * column definitions. Pass the resulting object to `Encryption({ schemas: [...] })` + * when initializing the client. + * + * The returned object is also a proxy that exposes each column builder directly, + * so you can reference columns as `users.email` when calling `encrypt`, `decrypt`, + * and `encryptQuery`. + * + * @deprecated EQL v2 authoring. The client authors EQL v3 — build tables with + * `encryptedTable` + the `types.*` factories from `@cipherstash/stack/v3`. This + * v2 builder remains only for reading/migrating legacy v2 data and will be + * removed once the v2 adapters are gone. + * + * @param tableName - The name of the database table this schema represents. + * @param columns - An object whose keys are logical column names and values are + * {@link EncryptedColumn} from {@link encryptedColumn}, or nested objects whose + * leaves are {@link EncryptedField} from {@link encryptedField}. + * @returns A `EncryptedTable<T> & T` that can be used as both a schema definition + * and a column accessor. + * + * @internal EQL v2 authoring. Not exported from any public subpath — retained + * only for reading/migrating legacy v2 data. + * + * @example + * ```typescript + * const users = encryptedTable("users", { + * email: encryptedColumn("email").equality().freeTextSearch(), + * address: encryptedColumn("address"), + * }) + * + * // Use as schema + * const client = await Encryption({ schemas: [users] }) + * + * // Use as column accessor + * await client.encrypt("hello@example.com", { column: users.email, table: users }) + * ``` + */ +export function encryptedTable<T extends EncryptedTableColumn>( + tableName: string, + columns: T, +): EncryptedTable<T> & T { + const tableBuilder = new EncryptedTable( + tableName, + columns, + ) as EncryptedTable<T> & T + + for (const [colName, colBuilder] of Object.entries(columns)) { + ;(tableBuilder as EncryptedTableColumn)[colName] = colBuilder + } + + return tableBuilder +} + +/** + * Define an encrypted column within a table schema. + * + * Creates a `EncryptedColumn` builder for the given column name. Chain index + * methods (`.equality()`, `.freeTextSearch()`, `.orderAndRange()`, + * `.searchableJson()`) and/or `.dataType()` to configure searchable encryption + * and the plaintext data type. + * + * @deprecated EQL v2 authoring. The client authors EQL v3 — define columns with + * the `types.*` concrete-domain factories from `@cipherstash/stack/v3`. This v2 + * builder remains only for reading/migrating legacy v2 data and will be removed + * once the v2 adapters are gone. + * + * @param columnName - The name of the database column to encrypt. + * @returns A new `EncryptedColumn` builder. + * + * @internal EQL v2 authoring. Not exported from any public subpath — retained + * only for reading/migrating legacy v2 data. + * + * @example + * ```typescript + * const users = encryptedTable("users", { + * email: encryptedColumn("email").equality().freeTextSearch().orderAndRange(), + * }) + * ``` + */ +export function encryptedColumn(columnName: string) { + return new EncryptedColumn(columnName) +} + +/** + * Define an encrypted field for use in nested or structured schemas. + * + * `encryptedField` is similar to {@link encryptedColumn} but creates an {@link EncryptedField} + * for nested fields that are encrypted but not searchable (no indexes). Use `.dataType()` + * to specify the plaintext type. + * + * @deprecated EQL v2 authoring. The client authors EQL v3 — model nested fields + * with a dotted v3 column path (`'profile.ssn': types.TextEq(...)`) from + * `@cipherstash/stack/v3`. This v2 builder remains only for reading/migrating + * legacy v2 data and will be removed once the v2 adapters are gone. + * + * @param valueName - The name of the value field. + * @returns A new `EncryptedField` builder. + * + * @internal EQL v2 authoring. Not exported from any public subpath — retained + * only for reading/migrating legacy v2 data. + * + * @example + * ```typescript + * const orders = encryptedTable("orders", { + * details: { + * amount: encryptedField("amount").dataType("number"), + * currency: encryptedField("currency"), + * }, + * }) + * ``` + */ +export function encryptedField(valueName: string) { + return new EncryptedField(valueName) +} + +/** + * Build an encrypt config from a list of encrypted tables. + * + * @param protectTables - The list of encrypted tables to build the config from. + * @returns An encrypt config object. + * + * @internal EQL v2 authoring. Not exported from any public subpath — the + * published `buildEncryptConfig` ships from `@cipherstash/stack/eql/v3`. This + * one is retained only for reading/migrating legacy v2 data. + * + * @example + * ```typescript + * const users = encryptedTable("users", { + * email: encryptedColumn("email").equality(), + * }) + * + * const orders = encryptedTable("orders", { + * amount: encryptedColumn("amount").dataType("number"), + * }) + * + * const config = buildEncryptConfig(users, orders) + * console.log(config) + * ``` + */ +export function buildEncryptConfig( + ...protectTables: Array<BuildableTable> +): EncryptConfig { + const config: EncryptConfig = { + v: 1, + tables: {}, + } + + for (const tb of protectTables) { + const tableDef = tb.build() + config.tables[tableDef.tableName] = tableDef.columns + } + + return config +} diff --git a/packages/stack/src/types-public.ts b/packages/stack/src/types-public.ts index bd1ec8b8f..c2fe74888 100644 --- a/packages/stack/src/types-public.ts +++ b/packages/stack/src/types-public.ts @@ -14,11 +14,6 @@ // Query types (public only) export type { AuthStrategy, - BuildableColumn, - BuildableQueryColumn, - BuildableTable, - BuildableTableColumns, - BuildableV3QueryableColumn, BulkDecryptedData, BulkDecryptPayload, BulkEncryptedData, @@ -30,8 +25,6 @@ export type { DecryptionResult, Encrypted, EncryptedFields, - EncryptedFromBuildableTable, - EncryptedFromSchema, EncryptedQuery, EncryptedQueryResult, EncryptedReturnType, @@ -44,8 +37,6 @@ export type { OtherFields, QueryTypeName, ScalarQueryTerm, - SearchTerm, - V3ClientConfig, } from '@/types' // Runtime values diff --git a/packages/stack/src/types.ts b/packages/stack/src/types.ts index 7195c4f7c..9b1a8be34 100644 --- a/packages/stack/src/types.ts +++ b/packages/stack/src/types.ts @@ -8,6 +8,7 @@ import type { newClient, QueryOpName, } from '@cipherstash/protect-ffi' +import type { AnyV3Table } from '@/eql/v3' import type { ColumnSchema, EncryptedColumn, @@ -16,7 +17,7 @@ import type { // Imported type-only for the TSDoc {@link} references in the comments below. encryptedColumn, encryptedField, -} from '@/schema' +} from '@/schema/internal' /** * A pluggable authentication strategy for ZeroKMS requests. Any object @@ -53,9 +54,8 @@ export type EncryptedValue = Brand<CipherStashEncrypted, 'encrypted'> /** Structural type representing encrypted data stored in the database. Always * carries a ciphertext. Covers BOTH wire formats: the EQL v2.3 payloads * (`k: "ct"` / `k: "sv"`) and the EQL v3 payloads (flat `{v: 3, i, c, …}` - * scalars and `{v: 3, k: "sv", i, sv}` SteVec documents). Which format - * `encrypt` produces is selected by the client's - * {@link ClientConfig.eqlVersion}; `decrypt` accepts both regardless. + * scalars and `{v: 3, k: "sv", i, sv}` SteVec documents). Schema authoring is + * v3-only, so `encrypt` always produces v3; `decrypt` accepts both regardless. * v3 scalars carry no `k` discriminator, so narrow with `'k' in payload` * before reading it. See also `EncryptedValue` for branded nominal typing, * and {@link EncryptedQuery} for the search-term shape returned by @@ -171,56 +171,27 @@ export type ClientConfig = { strategy?: AuthStrategy /** - * @deprecated The client authors EQL v3 — an all-v3 schema set forces `3` - * automatically and yields the typed client. This field remains only to read - * or write legacy EQL v2 during migration (e.g. `eqlVersion: 2` with a v2 - * schema set), and will be removed once the v2 adapters are gone. New code - * should not set it. + * Removed: Stack always authors EQL v3. Declared as `never` rather than + * omitted so the type rejects it wherever it appears — every other property + * here is optional, so excess-property checking was the only thing catching a + * leftover `eqlVersion`, and that fires on FRESH object literals alone. A + * shared config const (`const cfg = { …, eqlVersion: 2 }`) — the shape a + * v2 → v3 migration most plausibly has — therefore type-checked clean and + * threw at `Encryption()`. * - * The EQL wire version the client emits — one FFI client always emits - * exactly one wire format. + * `Encryption` keeps its runtime guard: JS and JSON callers bypass types + * entirely, so this is defence in depth, not a replacement for it. * - * - `2` (the protect-ffi default): payloads target the - * `eql_v2_encrypted` column type. - * - `3`: payloads target the per-capability `eql_v3` domains - * (`eql_v3.text_eq`, `eql_v3.integer_ord_ore`, `eql_v3.json`, …), - * derived from each column's `cast_as` and indexes. - * - * When omitted, {@link Encryption} auto-detects from the schema set: - * EQL v3 tables (from `@cipherstash/stack/v3`, marked by - * `buildColumnKeyMap()`) select `3`; v2 tables leave the FFI default - * (`2`) untouched. Mixing v2 and v3 tables in one client is an error — - * split them across two clients instead. - * - * `decrypt` accepts BOTH formats regardless of this setting, so v2 and - * v3 data can coexist during a migration. - * - * Under `3`, `encryptQuery` returns EQL v3 query operands (protect-ffi - * 0.29+): term-only scalar operands for the `eql_v3.query_<name>` twins, - * the `eql_v3.query_json` containment needle, and bare selector-hash - * strings for JSON path queries. + * One asymmetry the guard has to account for: without + * `exactOptionalPropertyTypes` — which this repo does not enable — `?: never` + * has declared type `undefined`, so `eqlVersion: undefined` is accepted here + * and no declaration can reject it. The runtime therefore tolerates exactly + * that value (it names no version) while still rejecting every real one, + * `eqlVersion: 3` included. */ - eqlVersion?: 2 | 3 -} - -/** - * {@link ClientConfig} for a client that authors EQL v3 — the same options - * minus the legacy `eqlVersion: 2` escape hatch. - * - * `Encryption` accepts this (not the full `ClientConfig`) alongside an all-v3 - * schema set. Forcing v2 wire over v3 schemas THROWS at setup — v2 payloads - * cannot satisfy an `eql_v3_*` domain — so admitting `2` there typed the call - * as `TypedEncryptionClient` for a call that returns no client at all. - * Adapters that are v3-only (`@cipherstash/prisma-next`, - * `@cipherstash/stack-drizzle`) should take this type for their pass-through - * config for the same reason. - */ -export type V3ClientConfig = Omit<ClientConfig, 'eqlVersion'> & { - eqlVersion?: 3 + eqlVersion?: never } -type AtLeastOneCsTable<T> = [T, ...T[]] - /** Structural contract for a column builder the client can consume for STORAGE * (`encrypt`). Satisfied by v2 `EncryptedColumn` / `EncryptedField` AND v3 * `EncryptedTextSearchColumn` — fields ARE encryptable, so this stays wide. */ @@ -283,8 +254,28 @@ export function hasBuildColumnKeyMap<T extends object>( ) } -export type EncryptionClientConfig = { - schemas: AtLeastOneCsTable<BuildableTable> +/** + * The `Encryption({ schemas, config })` argument, as a named type. + * + * The default MUST be a non-empty tuple, not `readonly AnyV3Table[]`: with the + * widened default, `S['length']` resolves to `number`, `number extends 0` is + * false, and the conditional hands back the widened array — so + * `const cfg: EncryptionClientConfig = { schemas: [] }` typechecked clean and + * threw at `Encryption()`. Excess-property checking only catches the FRESH + * literal `Encryption({ schemas: [] })`; a config object built once and passed + * around, which is exactly what this type exists to serve, slipped through. + * + * The conditional stays for an explicit `EncryptionClientConfig<[]>`, and the + * type parameter stays so a caller can pin their own schema tuple. The factory + * keeps accepting widened arrays inline through its overloads — that is a + * separate concern from what this exported type can prove. + * + * Mirrors `WasmEncryptionConfig` on the `wasm-inline` entry. + */ +export type EncryptionClientConfig< + S extends readonly AnyV3Table[] = readonly [AnyV3Table, ...AnyV3Table[]], +> = { + schemas: S['length'] extends 0 ? never : S config?: ClientConfig } diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index f3d31e3fe..c1ad98a1a 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -109,15 +109,15 @@ import { assertValidNumericValue, assertValueIndexCompatibility, } from '@/encryption/helpers/validation' -import { - type AnyV3Table, - buildEncryptConfig, - type V3DecryptedModel, - type V3EncryptedModel, - type V3ModelInput, -} from '@/eql/v3' -import { DATE_LIKE_CASTS } from '@/eql/v3/columns' +import { buildEncryptConfig } from '@/eql/v3' +import { type AnyEncryptedV3Column, DATE_LIKE_CASTS } from '@/eql/v3/columns' import { reconstructDateValue } from '@/eql/v3/date-reconstruction' +import type { + AnyV3Table, + V3DecryptedModel, + V3EncryptedModel, + V3ModelInput, +} from '@/eql/v3/table' import { type EncryptionError, EncryptionErrorTypes } from '@/errors' import { type CastAs, @@ -126,10 +126,8 @@ import { toEqlCastAs, } from '@/schema' import type { - BuildableV3QueryableColumn, Encrypted, EncryptedQuery, - EncryptOptions, Plaintext, QueryTypeName, } from '@/types' @@ -152,8 +150,7 @@ export { // the column classes, `buildEncryptConfig`, and the inference helpers — is the // v3 one, re-exported wholesale so an edge consumer authors v3 schemas from this // single import. The v2 builders are intentionally NOT exported here: the WASM -// path was never announced or documented for v2, and the edge targets v3. EQL v2 -// remains fully available on the native `@cipherstash/stack` entry. +// path was never announced or documented for v2, and the edge targets v3. export * from '@/eql/v3' // The failure vocabulary every method on this entry now returns. Exported here // so an edge consumer can discriminate `result.failure.type` from the SAME @@ -226,6 +223,25 @@ export type WasmClientConfig = { clientId: string /** Workspace client key — required by the WASM client. */ clientKey: string + + /** + * Removed: this entry always emits EQL v3. Declared as `never` rather than + * omitted because excess-property checking — the only thing that caught a + * leftover `eqlVersion` — fires on FRESH object literals alone. A shared + * config const, which is what a v2 → v3 migration actually holds, therefore + * type-checked clean and then hit the runtime guard in {@link Encryption}. + * + * On the shared base so it applies across all three auth arms below; a copy + * per arm would drift. Mirrors `ClientConfig.eqlVersion` on the native entry, + * whose guard this one is a byte-for-byte mirror of — the type is defence in + * depth for the JS/JSON callers that bypass it, not a replacement. + * + * As on the native entry, `?: never` still admits `eqlVersion: undefined` + * without `exactOptionalPropertyTypes` (not enabled in this repo) and cannot + * be made to reject it, so the runtime tolerates that one value and rejects + * every real one. + */ + eqlVersion?: never // Provide exactly one of `accessKey` (we build the strategy) or a // pre-built auth strategy — never both, never neither. } & ( @@ -288,11 +304,44 @@ export type WasmAuthStrategy = AccessKeyStrategy | OidcFederationStrategy export type WasmEncryptionConfig = { /** One or more EQL v3 tables, authored with `types` / `encryptedTable` from - * this entry. The WASM entry is EQL v3 only. */ - schemas: [AnyV3Table, ...AnyV3Table[]] + * this entry. The WASM entry is EQL v3 only. + * + * `readonly` (not the mutable tuple this once was) for the same reason the + * native entry was widened in A-4: a mutable tuple rejects every shape that + * is not an array literal — a shared `export const all: AnyV3Table[]`, a + * `ReadonlyArray`, anything spread. But non-emptiness stays HERE rather than + * moving entirely onto the {@link Encryption} overloads: a loose + * `readonly AnyV3Table[]` on the exported type laundered an empty set past + * both overloads, because `NonEmptyV3<readonly AnyV3Table[]>` resolves + * `S['length']` to `number`, not `0`. A config object built once and passed + * around — precisely what this type exists for — then reached the runtime + * throw with a clean typecheck. The overloads still accept widened arrays + * passed INLINE, which is the case A-4 was actually about. */ + schemas: readonly [AnyV3Table, ...AnyV3Table[]] config: WasmClientConfig } +/** + * The implementation's view of {@link WasmEncryptionConfig}: same shape, loose + * array. Deliberately not exported — it is what the overload implementation + * signature destructures, and exporting it would reopen the laundering path + * documented above. + */ +type WasmEncryptionConfigInput = { + schemas: readonly AnyV3Table[] + config: WasmClientConfig +} + +export type WasmEncryptOptions = { + table: AnyV3Table + column: AnyEncryptedV3Column +} + +type WasmQueryableV3Column = Extract< + AnyEncryptedV3Column, + { isQueryable(): true } +> + /** * Options for {@link WasmEncryptionClient.encryptQuery}. * @@ -302,9 +351,9 @@ export type WasmEncryptionConfig = { */ export type WasmEncryptQueryOptions = { /** The `encryptedTable(...)` the column belongs to. */ - table: EncryptOptions['table'] + table: AnyV3Table /** The queryable v3 column the term targets, e.g. `users.email`. */ - column: BuildableV3QueryableColumn + column: WasmQueryableV3Column /** * Which of the column's indexes the term targets: * @@ -340,7 +389,7 @@ export type WasmQueryTerm = WasmEncryptQueryOptions & { * One storage value in a {@link WasmEncryptionClient.bulkEncrypt} batch. * * Each entry carries its OWN table and column, rather than the batch taking a - * single `EncryptOptions` the way {@link WasmEncryptionClient.encrypt} does. + * single `WasmEncryptOptions` the way {@link WasmEncryptionClient.encrypt} does. * That mirrors {@link WasmQueryTerm} — and it is what makes the round-trip * saving worth having: rendering a page of rows means encrypting several * columns across many rows, and a single-column batch would still cost one @@ -364,8 +413,8 @@ export type WasmBulkPlaintext = { * every call site to satisfy a check the runtime does anyway. */ plaintext: WasmPlaintext | undefined - table: EncryptOptions['table'] - column: EncryptOptions['column'] + table: AnyV3Table + column: AnyEncryptedV3Column } /** @@ -683,12 +732,15 @@ export class WasmEncryptionClient { * clients derive the table from the payloads instead, so callers that hold a * client structurally cannot tell the two apart. * - * `encryptedDynamoDB` is the caller that cares: its legacy EQL v2 read path - * deliberately omits the table (a v2 table means nothing to a v3 - * reconstructor map), which reached `requireTable` with `undefined` and threw - * a TypeError about `tableName` pointing nowhere near the cause. Declared - * rather than sniffed so the check is a stated capability, not a guess about - * arity or constructor name (#772 review, finding 10). + * Declared rather than sniffed so the capability is a stated fact, not a + * guess about arity or constructor name (#772 review, finding 10). + * + * This does NOT imply the entry cannot serve a legacy EQL v2 read. It once + * did: `encryptedDynamoDB` used to omit the table on that path, reaching + * `requireTable` with `undefined`. It now forwards the registered v3 table on + * every read, v2 storage included — the reconstructor map is keyed by the + * current schema either way, and protect-ffi's `decrypt` accepts both wire + * generations regardless of the client's `eqlVersion`. */ readonly requiresTableForDecrypt = true @@ -750,7 +802,7 @@ export class WasmEncryptionClient { async encrypt( plaintext: WasmPlaintext, - opts: EncryptOptions, + opts: WasmEncryptOptions, ): Promise<WasmResult<Encrypted>> { return wasmResult(async () => { const ffiOpts = { @@ -1366,6 +1418,17 @@ export class WasmEncryptionClient { } } +/** + * `[]` must stay a compile error, but the constraint cannot carry that: it + * would reject widened and readonly arrays again. The native entry solves this + * the same way — see `NonEmptyV3` in `encryption/index.ts` for why the + * conditional sits on the PROPERTY, and why a second overload is needed for + * callers that are themselves generic over their schemas. + */ +type NonEmptyV3<S extends readonly AnyV3Table[]> = S['length'] extends 0 + ? never + : S + /** * Initialize a WASM-backed encryption client. * @@ -1373,8 +1436,18 @@ export class WasmEncryptionClient { * factory, but constructs the protect-ffi client via the WASM strategy * API. Use from Deno / Edge / Workers / Bun. */ +export function Encryption< + const S extends readonly [AnyV3Table, ...AnyV3Table[]], +>(config: { + schemas: S + config: WasmClientConfig +}): Promise<WasmEncryptionClient> +export function Encryption<const S extends readonly AnyV3Table[]>(config: { + schemas: NonEmptyV3<S> + config: WasmClientConfig +}): Promise<WasmEncryptionClient> export async function Encryption( - config: WasmEncryptionConfig, + config: WasmEncryptionConfigInput, ): Promise<WasmEncryptionClient> { const { schemas, config: clientConfig } = config @@ -1384,15 +1457,40 @@ export async function Encryption( ) } + // Mirrors the native entry's guard (#815). The two entries disagreed about v2 + // before that issue, so a config the native factory rejects outright must not + // pass silently here. This entry cannot emit v2 — `eqlVersion: 3` is hardcoded + // below — so an `eqlVersion` key is always a stale config, never a request we + // could honour. `Object.hasOwn` rejects the PRESENCE of the key, including an + // explicit `eqlVersion: 3`, so the removal is discovered at the source rather + // than the field lingering as a no-op that looks load-bearing. + // + // An explicit `undefined` is the one exception, on both entries: it names no + // version, and `eqlVersion?: never` cannot reject it at the type level without + // `exactOptionalPropertyTypes` (not enabled here), so throwing on it would + // fail a config the emitted declarations already accepted. + if ( + clientConfig && + Object.hasOwn(clientConfig, 'eqlVersion') && + clientConfig.eqlVersion !== undefined + ) { + throw new Error( + '[encryption]: `config.eqlVersion` has been removed — @cipherstash/stack always authors EQL v3. Remove the field.', + ) + } + // The WASM entry is EQL v3 only. The types enforce v3 tables, but a plain-JS // caller can bypass that — reject a non-v3 table (one lacking the v3 // `buildColumnKeyMap` marker) with a clear message rather than pinning the // client to v3 wire against a v2 schema and failing opaquely inside the FFI. + // The message must not point anywhere else for v2 authoring: since #815 the + // native entry rejects it too, so a referral would only cost the reader a + // second rejection. Name the one thing v2 payloads are still good for. for (const table of schemas) { const isV3 = hasBuildColumnKeyMap(table) if (!isV3) { throw new Error( - '[encryption]: `@cipherstash/stack/wasm-inline` is EQL v3 only — author schemas with `types` / `encryptedTable` from this entry. (EQL v2 is available on the native `@cipherstash/stack` entry.)', + '[encryption]: `@cipherstash/stack/wasm-inline` is EQL v3 only — author schemas with `types` / `encryptedTable` from this entry. EQL v2 authoring has been removed from every entry; the client still decrypts existing v2 payloads.', ) } } @@ -1430,8 +1528,8 @@ export async function Encryption( * * The Node entry of protect-ffi performs this normalization internally * via `normalizeEncryptConfig.js`; the WASM bindings do not. Without - * this, the WASM client rejects an `encryptedColumn('email')` (which - * defaults to `cast_as: 'string'`) with + * this, the WASM client rejects a column config whose SDK-facing cast is + * `string` with * `unknown variant `string`, expected one of `big_int`, …`. * * `toEqlCastAs` is exhaustive over the current `CastAs` union; if a new @@ -1465,20 +1563,22 @@ export function normalizeCastAs(config: EncryptConfig): unknown { } /** - * Resolve a column's name structurally. Accepts any column builder exposing - * `getName()` — v2 `EncryptedColumn` / `EncryptedField` AND v3 column builders - * (e.g. `EncryptedTextSearchColumn`) alike — matching the structural - * `BuildableColumn` contract that `EncryptOptions.column` was widened to. + * Resolve a column's name structurally: any builder exposing `getName()` + * qualifies, matching the structural v3 column contract accepted by + * {@link WasmEncryptOptions.column}. + * + * Structural rather than an `instanceof` gate because the v3 column builders are + * a family of classes (`EncryptedTextSearchColumn`, `EncryptedIntegerOrdColumn`, + * …), and enumerating them here would silently reject any domain added later — + * a class of bug the type checker cannot see, since the parameter type is + * structural. `getName()` is the whole contract this function needs. * - * An `instanceof EncryptedColumn || EncryptedField` gate would type-check after - * the widening but throw at runtime for a v3 column, breaking the type promise; - * resolving the name structurally keeps the wasm-inline encrypt entry honest. - * The `typeof` check still fails loudly for plain JS callers passing a value - * that is not a column builder. + * The `typeof` check still fails loudly for plain-JS callers passing a value + * that is not a column builder at all. * * @internal exported for unit-test coverage. */ -export function getColumnName(col: EncryptOptions['column']): string { +export function getColumnName(col: AnyEncryptedV3Column): string { if (typeof col?.getName === 'function') { return col.getName() } diff --git a/packages/stack/tsup.config.ts b/packages/stack/tsup.config.ts index 413934516..a96c010c4 100644 --- a/packages/stack/tsup.config.ts +++ b/packages/stack/tsup.config.ts @@ -10,7 +10,6 @@ export default defineConfig([ { entry: [ 'src/index.ts', - 'src/client.ts', 'src/types-public.ts', 'src/identity/index.ts', 'src/schema/index.ts', diff --git a/packages/stack/vitest.config.ts b/packages/stack/vitest.config.ts index 06413417f..5ae79ab89 100644 --- a/packages/stack/vitest.config.ts +++ b/packages/stack/vitest.config.ts @@ -34,7 +34,7 @@ export default defineConfig({ // with `--typecheck.only` so the runtime suites do NOT also execute. tsconfig: './tsconfig.typecheck.json', // Type coverage lives in `*.test-d.ts`. M1 (the factory rejecting the - // `TypedEncryptionClient` that `EncryptionV3` returns) slipped through + // `EncryptionClient` that `Encryption` returns) slipped through // because the runtime `*.test.ts` suites are not typechecked. Rather than // widen to those files — the drizzle-v3 suites are pervasively loose-typed // by design (dynamic domain matrix: `as never` tables, untyped mock diff --git a/packages/test-kit/src/index.ts b/packages/test-kit/src/index.ts index ebfbc24f1..934847f38 100644 --- a/packages/test-kit/src/index.ts +++ b/packages/test-kit/src/index.ts @@ -74,3 +74,16 @@ export { type RowPlan, type TableSpec, } from './rows.ts' +export { + V2_MINT_DEFERRED, + V2_UNREACHABLE_CAST_AS, + type V2DeferredDomain, + type V2FixtureCase, + type V2FixtureDomain, + type V2FixturePlan, + v2FixtureColumns, + v2FixturePlan, + v2ModelRows, + v2OpeIndexedDomains, + v2UndeclaredCastAs, +} from './v2-fixtures.ts' diff --git a/packages/test-kit/src/v2-fixtures.ts b/packages/test-kit/src/v2-fixtures.ts new file mode 100644 index 000000000..6b9afcd57 --- /dev/null +++ b/packages/test-kit/src/v2-fixtures.ts @@ -0,0 +1,324 @@ +/** + * The legacy-EQL-v2 fixture matrix: which catalog domains a v2 READ suite mints + * fixtures for, and — explicitly, with a written reason — which it does not. + * + * Schema authoring is EQL v3-only, so nothing in the product can write a v2 + * payload any more. But customers hold v2 rows written before they upgraded, and + * those must stay readable forever. The only way to test that is to mint v2 + * fixtures straight through protect-ffi (`newClient({ eqlVersion: 2 })`), which + * makes every such suite integration-only. + * + * This module is the SELECTION and PLANNING half of those suites, shared so that + * the native, `wasm-inline` and DynamoDB surfaces cannot quietly disagree about + * which domains they claim to cover. It deliberately builds nothing entry- + * specific: it hands back column builders, a batched fixture plan, and a + * row-assembly function, and each suite supplies its own `encryptedTable` / + * `Encryption` / FFI entry point. + * + * COVERAGE DEFAULTS TO ON. {@link V2_MINT_DEFERRED} is a `Partial<Record< + * EqlV3TypeName, string>>`, so a domain added to the catalog is exercised until + * someone writes down why it should not be — the same discipline as + * `DomainSpec.deferred`, and for the same reason: a silently-missing domain is + * the failure mode that made this file necessary. The annotation also rejects a + * key that is no longer a domain, so the exclusions cannot outlive their subject. + * + * One honest limit worth stating: these fixtures are v2 WIRE written by the + * CURRENT client, not bytes written by the client a customer actually ran in + * 2024. That is the strongest fixture the shipped binding can produce; it pins + * the envelope and the plaintext encoding, not the whole history of them. + */ +import type { AnyEncryptedV3Column, JsonValue } from '@cipherstash/stack/eql/v3' +import type { CastAs } from '@cipherstash/stack/schema' +import { + type DomainSpec, + type EqlV3TypeName, + eqlTypeSlug, + typedEntries, + V3_MATRIX, +} from './catalog.ts' + +/** + * Why `public.eql_v3_json_search` has no v2 fixture: the shipped client refuses + * to write one. This is a hard refusal in cipherstash-client 0.42, not a gap in + * this harness — the strings are in the binding itself: + * + * "eqlVersion 2 cannot emit ste_vec ciphertexts with cipherstash-client 0.42; + * use eqlVersion 3" + * "SteVec documents use the v3 envelope wire format and cannot be emitted as a + * v2 storage payload; use encrypt_eql_v3" + * + * Reading a legacy v2 SteVec document is still supported (`decrypt` accepts both + * wire generations, and the v2 SteVec shapes remain in protect-ffi's input + * union) — it simply cannot be proven from a freshly-minted fixture, because no + * fresh v2 SteVec fixture can exist. This is the one deferral that costs a whole + * plaintext axis, which is why it is also listed in + * {@link V2_UNREACHABLE_CAST_AS}. + */ +const STE_VEC_NOT_MINTABLE = + 'cipherstash-client 0.42 refuses to emit a ste_vec ciphertext in EQL v2 mode ' + + '("eqlVersion 2 cannot emit ste_vec ciphertexts with cipherstash-client 0.42; ' + + 'use eqlVersion 3"), so no v2 JSON fixture can be minted at all. Legacy v2 ' + + 'SteVec documents on disk remain decryptable; that read is simply not ' + + 'reachable from a fixture this harness can produce.' + +/** + * Why the CLLW-OPE (`_ord`) domains have no v2 fixture — and this one is a + * judgement about the DATA, not a workaround for a flaky test. + * + * A v2 payload from an `ope`-indexed column is not legacy data; it is data that + * never existed. Two independent facts say so: + * + * - The EQL v2 scalar payload has no slot for an OPE term. Its shape is + * `{ k, v, i, c, hm?, bf?, ob? }` — HMAC, bloom filter, block-ORE. `op` is an + * EQL v3 addition (protect-ffi's `EncryptedScalar`). + * - The v2 authoring path this branch removed never emitted `ope` anyway. Its + * `orderAndRange()` set `indexes.ore`; `ope` appeared only in the config + * VALIDATOR, never in a built column. + * + * So minting one would exercise a configuration no customer can be holding, and + * whose behaviour under a v2-mode `encrypt` is undefined by construction (the + * binding's own diagnostic for a term with nowhere to go is "Unknown Index Term + * for column '…' in table '…'"). + * + * It costs no read coverage. Decrypt reconstruction is driven by `cast_as`, not + * by indexes, and every deferred domain here shares its `cast_as` with a covered + * sibling — `date_ord` with `date` / `date_eq` / `date_ord_ore`, `text_search` + * with `text` / `text_eq`, and so on. {@link v2UndeclaredCastAs} is the + * executable form of that claim, so it cannot rot into a false comment. + * + * The block-ORE (`_ord_ore`) domains are NOT deferred, and the contrast matters: + * `ore` is precisely what the v2 authoring path emitted for an ordered column, + * so those payloads are the real legacy shape. (They carry a separate + * `DomainSpec.deferred` in the catalog, but that is about a Postgres operator + * class being superuser-only — a storage concern, irrelevant to decrypt, and + * deliberately not consulted here.) + */ +const OPE_IS_NOT_A_V2_SHAPE = + 'CLLW-OPE has no representation in the EQL v2 wire (a v2 scalar payload ' + + 'carries hm/bf/ob only, never op), and the removed v2 authoring path emitted ' + + '`ore` for ordered columns, never `ope`. A v2 payload from an ope-indexed ' + + 'column is therefore not legacy data but data that never existed. No read ' + + 'coverage is lost: this domain shares its cast_as — the axis decrypt actually ' + + 'reconstructs from — with a covered sibling.' + +/** + * Domains the v2 fixture matrix does not mint, and why. + * + * ANNOTATED, not `satisfies`: the annotation is what makes an unknown or stale + * key a compile error, and what makes a NEWLY ADDED domain default to covered. + * Deleting a row here re-enables the domain; that is the intended way to change + * the coverage, and it shows up as a reviewable diff either way. + */ +export const V2_MINT_DEFERRED: Partial<Record<EqlV3TypeName, string>> = { + 'public.eql_v3_json_search': STE_VEC_NOT_MINTABLE, + + // Every `ope`-indexed domain. Kept as an explicit list rather than derived + // from `indexes.ope` so that a future ope-indexed domain lands in the covered + // set and fails loudly, instead of inheriting an exclusion nobody reviewed. + // `v2OpeIndexedDomains()` cross-checks the two, so the list cannot drift from + // the rule it claims to encode. + 'public.eql_v3_integer_ord': OPE_IS_NOT_A_V2_SHAPE, + 'public.eql_v3_smallint_ord': OPE_IS_NOT_A_V2_SHAPE, + 'public.eql_v3_bigint_ord': OPE_IS_NOT_A_V2_SHAPE, + 'public.eql_v3_date_ord': OPE_IS_NOT_A_V2_SHAPE, + 'public.eql_v3_timestamp_ord': OPE_IS_NOT_A_V2_SHAPE, + 'public.eql_v3_numeric_ord': OPE_IS_NOT_A_V2_SHAPE, + 'public.eql_v3_real_ord': OPE_IS_NOT_A_V2_SHAPE, + 'public.eql_v3_double_ord': OPE_IS_NOT_A_V2_SHAPE, + 'public.eql_v3_text_ord': OPE_IS_NOT_A_V2_SHAPE, + 'public.eql_v3_text_search': OPE_IS_NOT_A_V2_SHAPE, +} + +/** + * Plaintext axes (`cast_as`) that NO mintable domain reaches, and why. + * + * Deferring a domain is usually free — a sibling covers the same `cast_as`, and + * `cast_as` is the only thing decrypt reconstruction reads. Deferring the LAST + * domain on an axis is not free, and this map is where that has to be admitted + * out loud. {@link v2UndeclaredCastAs} turns "admitted" into a test. + */ +export const V2_UNREACHABLE_CAST_AS: Partial<Record<CastAs, string>> = { + json: STE_VEC_NOT_MINTABLE, +} + +/** One domain the matrix mints fixtures for. */ +export type V2FixtureDomain = Readonly<{ + /** Physical column name in the fixture table, e.g. `eql_v3_date_eq`. */ + slug: string + eqlType: EqlV3TypeName + spec: DomainSpec +}> + +/** One domain the matrix does not mint, with the reason it does not. */ +export type V2DeferredDomain = Readonly<{ + slug: string + eqlType: EqlV3TypeName + reason: string +}> + +/** + * One (domain, sample) fixture: what to encrypt, and what decrypting it must + * give back. + * + * `plaintext` and `sample` differ only for the date-like domains, and the split + * is what makes this harness entry-agnostic. A `Date` handed to the NATIVE + * binding survives because neon's extractor runs `JSON.stringify` on it; the + * WASM binding has no such step and its own entry converts explicitly. Handing + * the FFI the ISO string both paths would end up with removes the difference. + * `sample` stays the `Date`, because that is what the v3 table's `cast_as` + * obliges `decryptModel` to reconstruct. + */ +export type V2FixtureCase = Readonly<{ + /** `public.eql_v3_date_eq #1` — a vitest `it.each` label. */ + label: string + slug: string + eqlType: EqlV3TypeName + /** The plaintext axis decrypt reconstructs from. The thing actually under test. */ + castAs: CastAs + /** Expected value after decrypt + reconstruction. */ + sample: DomainSpec['samples'][number] + /** Value to hand the FFI. Date-like samples arrive here as ISO strings. */ + plaintext: string | number | bigint | boolean | JsonValue + /** Which batched model row this fixture belongs to. */ + row: number +}> + +/** + * The whole batched plan. `cases` is also the ENCRYPT ORDER: protect-ffi's + * `encryptBulk` correlates results positionally, so a suite can zip its output + * straight against this array (and {@link v2ModelRows} does). + */ +export type V2FixturePlan = Readonly<{ + domains: readonly V2FixtureDomain[] + cases: readonly V2FixtureCase[] + /** Number of batched model rows: the widest domain's sample count. */ + rowCount: number + deferred: readonly V2DeferredDomain[] +}> + +function catalogRows(): Array<[EqlV3TypeName, DomainSpec]> { + // Explicit type arguments for the same reason as `matrix-crypto`: `V3_MATRIX` + // is `as const`, so without them `spec` infers as the union of 40 distinct row + // literals and optional fields fail to resolve on rows that omit them. + return typedEntries<EqlV3TypeName, DomainSpec>(V3_MATRIX) +} + +/** + * Domains whose configured indexes include `ope`. Exported so a suite can assert + * that {@link V2_MINT_DEFERRED} still says exactly what + * {@link OPE_IS_NOT_A_V2_SHAPE} claims it says — the list is hand-written on + * purpose (so a new domain defaults to covered), and this is what stops it + * drifting away from its own rationale. + */ +export function v2OpeIndexedDomains(): EqlV3TypeName[] { + return catalogRows() + .filter(([, spec]) => spec.indexes?.ope !== undefined) + .map(([eqlType]) => eqlType) +} + +/** Build the batched fixture plan from the catalog. */ +export function v2FixturePlan(): V2FixturePlan { + const domains: V2FixtureDomain[] = [] + const deferred: V2DeferredDomain[] = [] + + for (const [eqlType, spec] of catalogRows()) { + const slug = eqlTypeSlug(eqlType) + const reason = V2_MINT_DEFERRED[eqlType] + if (reason === undefined) { + domains.push({ slug, eqlType, spec }) + } else { + deferred.push({ slug, eqlType, reason }) + } + } + + const cases: V2FixtureCase[] = [] + let rowCount = 0 + for (const domain of domains) { + rowCount = Math.max(rowCount, domain.spec.samples.length) + domain.spec.samples.forEach((sample, row) => { + cases.push({ + label: `${domain.eqlType} #${row}`, + slug: domain.slug, + eqlType: domain.eqlType, + castAs: domain.spec.castAs, + sample, + plaintext: sample instanceof Date ? sample.toISOString() : sample, + row, + }) + }) + } + + return { domains, cases, rowCount, deferred } +} + +/** + * One column builder per mintable domain, keyed by slug — the shape + * `encryptedTable()` takes. Column names are the catalog slugs, which are unique + * and never collide with `EncryptedTable`'s reserved property names. + */ +export function v2FixtureColumns( + plan: V2FixturePlan, +): Record<string, AnyEncryptedV3Column> { + const columns: Record<string, AnyEncryptedV3Column> = {} + for (const domain of plan.domains) { + columns[domain.slug] = domain.spec.builder(domain.slug) + } + return columns +} + +/** + * Reassemble a positional `encryptBulk` result into model rows: row `i` carries + * every domain's `samples[i]`, and domains with fewer samples are simply absent + * from the later rows (the model paths skip absent fields). + * + * Batching this way is what keeps a ~40-domain matrix to two network calls + * instead of ~100. THROWS on a length mismatch rather than silently producing + * short rows: a mis-zipped batch would assert the wrong domain's value against + * the wrong sample and could pass by coincidence. + */ +export function v2ModelRows<T>( + plan: V2FixturePlan, + payloads: readonly T[], +): Array<Record<string, T>> { + if (payloads.length !== plan.cases.length) { + throw new Error( + `v2ModelRows: expected one payload per fixture case (${plan.cases.length}), got ${payloads.length}`, + ) + } + + const rows: Array<Record<string, T>> = Array.from( + { length: plan.rowCount }, + () => ({}), + ) + + plan.cases.forEach((fixtureCase, i) => { + const row = rows[fixtureCase.row] + const payload = payloads[i] + if (row === undefined || payload === undefined) { + throw new Error( + `v2ModelRows: no payload for ${fixtureCase.label} (row ${fixtureCase.row})`, + ) + } + row[fixtureCase.slug] = payload + }) + + return rows +} + +/** + * Plaintext axes that the plan neither covers nor declares unreachable. + * + * Non-empty means real coverage was lost without anyone saying so — a domain was + * deferred that turned out to be the last one on its axis, or a new axis arrived + * already deferred. Suites assert this is empty. + */ +export function v2UndeclaredCastAs(plan: V2FixturePlan): CastAs[] { + const covered = new Set(plan.cases.map((fixtureCase) => fixtureCase.castAs)) + const undeclared = new Set<CastAs>() + for (const [, spec] of catalogRows()) { + if (covered.has(spec.castAs)) continue + if (V2_UNREACHABLE_CAST_AS[spec.castAs] !== undefined) continue + undeclared.add(spec.castAs) + } + return [...undeclared] +} diff --git a/scripts/__tests__/integration-workflow-paths.test.mjs b/scripts/__tests__/integration-workflow-paths.test.mjs new file mode 100644 index 000000000..d47c67bba --- /dev/null +++ b/scripts/__tests__/integration-workflow-paths.test.mjs @@ -0,0 +1,266 @@ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import yaml from 'js-yaml' +import { describe, expect, it } from 'vitest' + +/** + * An integration workflow only runs when its `paths:` filter matches the diff. + * So a source directory that the selected suites import, but that no `paths:` + * entry covers, is live coverage that silently does not run: edit only that + * directory and the job the code depends on never starts. + * + * `packages/stack/src/dynamodb/**` was exactly that (#815 review). The only + * live EQL v2 read coverage in the repo lives in + * `integration/shared/v2-decrypt-compat.integration.test.ts`, which exercises + * the DynamoDB legacy read path — and `grep -rn dynamodb .github/` returned + * nothing at all, so a change to that path (or a `protect-ffi` bump that broke + * v2 deserialization) would not have run it. + * + * Rather than hardcode "dynamodb must be listed", this derives the requirement: + * whatever the selected suites import via the `@/` alias must be covered. A new + * import in an integration suite therefore fails here until the filter follows. + * + * The same argument applies to what the suites import from OUTSIDE the repo. + * Those v2 suites mint their fixtures with `@cipherstash/protect-ffi` directly, + * and a bump of that native module edits only a dependency manifest — no source + * directory — so it too must appear in the filter. `importedDependencyManifests` + * derives which manifest that is (package manifest for exact pins, + * `pnpm-workspace.yaml` for `catalog:` ones). + * + * Finally, GitHub Actions has no YAML anchors, so every filter is written twice. + * A one-sided edit disables the job on pull requests while leaving it green on + * `main` — the exact inversion of what you want — so the two copies are + * compared directly. + */ + +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '../../..') +const STACK_SRC = 'packages/stack/src' +const STACK_MANIFEST = 'packages/stack/package.json' +const CATALOG_MANIFEST = 'pnpm-workspace.yaml' + +/** Both trigger events, in the order GitHub evaluates them. */ +const TRIGGER_EVENTS = ['push', 'pull_request'] + +function readWorkflow(relPath) { + return yaml.load(readFileSync(join(REPO_ROOT, relPath), 'utf8')) +} + +/** + * The integration workflows, discovered rather than listed: any workflow whose + * `CS_IT_SUITE` globs select suites out of `packages/stack/` is in scope. A + * fourth integration job added later is therefore held to the same bar without + * anyone remembering to add it here. + */ +function discoverWorkflows() { + const dir = join(REPO_ROOT, '.github/workflows') + return readdirSync(dir) + .filter((name) => name.endsWith('.yml') || name.endsWith('.yaml')) + .map((name) => `.github/workflows/${name}`) + .filter( + (relPath) => suiteFiles(suiteGlobs(readWorkflow(relPath))).length > 0, + ) + .sort() +} + +/** + * `on:` parses as the boolean `true` under YAML 1.1 (the "Norway problem"), + * which is why this reads both keys rather than `wf.on`. + */ +function triggerFilters(wf) { + const on = wf.on ?? wf[true] + return Object.fromEntries( + TRIGGER_EVENTS.map((event) => [event, on?.[event]?.paths]), + ) +} + +function triggerBlocks(wf) { + const on = wf.on ?? wf[true] + return TRIGGER_EVENTS.map((event) => on?.[event]).filter( + (block) => block && Array.isArray(block.paths), + ) +} + +/** Every `CS_IT_SUITE` glob set declared anywhere in the workflow. */ +function suiteGlobs(wf) { + const globs = [] + for (const job of Object.values(wf.jobs ?? {})) { + const raw = job.env?.CS_IT_SUITE + if (!raw) continue + globs.push( + ...String(raw) + .split(',') + .map((g) => g.trim()) + .filter(Boolean), + ) + } + return globs +} + +/** Test files under `packages/stack/` matching a `CS_IT_SUITE` glob. */ +function suiteFiles(globs) { + const files = [] + const walk = (dir) => { + if (!existsSync(dir)) return + for (const entry of readdirSync(dir)) { + const full = join(dir, entry) + if (statSync(full).isDirectory()) walk(full) + else if (full.endsWith('.integration.test.ts')) files.push(full) + } + } + for (const glob of globs) { + // Walk the literal prefix; the glob tail only ever narrows to + // `*.integration.test.ts`, which the walk already filters on. + const prefix = glob.split('/').slice(0, 2).join('/') + walk(join(REPO_ROOT, 'packages/stack', prefix)) + } + return [...new Set(files)] +} + +/** + * Resolve the `@/`-aliased imports of a suite to the source paths a `paths:` + * filter would have to cover. `@/eql/v3` is a directory; `@/types` is a file. + */ +function importedSourcePaths(file) { + const source = readFileSync(file, 'utf8') + const targets = new Set() + for (const match of source.matchAll(/from '@\/([^']+)'/g)) { + const specifier = match[1] + const asDir = join(REPO_ROOT, STACK_SRC, specifier) + if (existsSync(asDir) && statSync(asDir).isDirectory()) { + targets.add(`${STACK_SRC}/${specifier}/`) + continue + } + // A module file (`@/types`, `@/encryption/v3`). Its own directory is what a + // `paths:` glob would cover, so record the file and let `isCovered` match + // either the file itself or an ancestor glob. + targets.add(`${STACK_SRC}/${specifier}.ts`) + } + return targets +} + +/** `packages/stack`'s dependency declarations, specifier -> version range. */ +function stackDependencies() { + const manifest = JSON.parse( + readFileSync(join(REPO_ROOT, STACK_MANIFEST), 'utf8'), + ) + return { + ...manifest.dependencies, + ...manifest.devDependencies, + ...manifest.peerDependencies, + ...manifest.optionalDependencies, + } +} + +/** + * The manifests that PIN the versions of the third-party packages a suite + * imports directly — the files a dependency bump actually edits. + * + * This is the second half of the same argument as `importedSourcePaths`. That + * one covers first-party source; this one covers the native/WASM modules the + * suites are proving the behaviour of. `integration/shared/v2-decrypt-compat` + * and its `integration/wasm/` twin are the repo's only live EQL v2 read + * coverage, and both mint their fixtures by importing `@cipherstash/protect-ffi` + * directly — so a protect-ffi bump is precisely the change most able to break + * them, and precisely the change that touches no source directory at all. + * + * Specifiers with no entry in `packages/stack/package.json` (e.g. + * `@cipherstash/test-kit`, resolved through tsconfig `paths` to a workspace + * package) are skipped: `importedSourcePaths`' sibling globs already cover them. + */ +function importedDependencyManifests(file, deps) { + const source = readFileSync(file, 'utf8') + const targets = new Set() + for (const match of source.matchAll(/from '([^'@.][^']*|@[^/']+\/[^']+)'/g)) { + const specifier = match[1] + const parts = specifier.split('/') + const pkg = specifier.startsWith('@') + ? parts.slice(0, 2).join('/') + : parts[0] + const range = deps[pkg] + if (!range) continue + targets.add(STACK_MANIFEST) + // A `catalog:` specifier carries no version — the number lives in + // `pnpm-workspace.yaml`, so that is the file a bump edits. + if (String(range).startsWith('catalog:')) targets.add(CATALOG_MANIFEST) + } + return targets +} + +/** Does any `paths:` entry match this source path? */ +function isCovered(target, paths) { + return paths.some((entry) => { + const literal = entry.replace(/\*\*$/, '').replace(/\/$/, '') + return target.startsWith(`${literal}/`) || target === literal + }) +} + +const WORKFLOWS = discoverWorkflows() + +describe('integration workflow paths filters', () => { + it('finds the integration workflows to check', () => { + expect(WORKFLOWS.length).toBeGreaterThan(0) + }) + + for (const relPath of WORKFLOWS) { + /** + * GitHub Actions has no YAML anchors, so each filter is written out twice. + * A one-sided edit is silent: the workflow keeps running on `push` to main + * and stops running on the PR that introduced the break, which is the only + * time it matters. Compare the two lists rather than trusting the comment. + */ + it(`${relPath} repeats an identical paths filter under push and pull_request`, () => { + const filters = triggerFilters(readWorkflow(relPath)) + for (const event of TRIGGER_EVENTS) { + expect(Array.isArray(filters[event])).toBe(true) + } + expect(filters.pull_request).toEqual(filters.push) + }) + + it(`${relPath} triggers on the manifests pinning its suites' dependencies`, () => { + const wf = readWorkflow(relPath) + const blocks = triggerBlocks(wf) + expect(blocks.length).toBe(TRIGGER_EVENTS.length) + + const deps = stackDependencies() + const files = suiteFiles(suiteGlobs(wf)) + expect(files.length).toBeGreaterThan(0) + + const required = new Set() + for (const file of files) { + for (const target of importedDependencyManifests(file, deps)) { + required.add(target) + } + } + expect(required.size).toBeGreaterThan(0) + + for (const block of blocks) { + const uncovered = [...required].filter( + (target) => !isCovered(target, block.paths), + ) + expect(uncovered).toEqual([]) + } + }) + + it(`${relPath} triggers on every source path its suites import`, () => { + const wf = readWorkflow(relPath) + const blocks = triggerBlocks(wf) + expect(blocks.length).toBeGreaterThan(0) + + const files = suiteFiles(suiteGlobs(wf)) + expect(files.length).toBeGreaterThan(0) + + const required = new Set() + for (const file of files) { + for (const target of importedSourcePaths(file)) required.add(target) + } + + for (const block of blocks) { + const uncovered = [...required].filter( + (target) => !isCovered(target, block.paths), + ) + expect(uncovered).toEqual([]) + } + }) + } +}) diff --git a/skills/stash-dynamodb/SKILL.md b/skills/stash-dynamodb/SKILL.md index d6ba5ed19..6facd6f96 100644 --- a/skills/stash-dynamodb/SKILL.md +++ b/skills/stash-dynamodb/SKILL.md @@ -1,6 +1,6 @@ --- name: stash-dynamodb -description: Integrate CipherStash encryption with Amazon DynamoDB using @cipherstash/stack/dynamodb. Covers the encryptedDynamoDB helper for encrypting items before PutItem and decrypting after GetItem with EQL v3 (types.* domains) or EQL v2 schemas, bulk encrypt/decrypt for BatchWrite and BatchGet, querying with encrypted partition and sort keys via HMAC attributes, nested object encryption, audit logging, and the DynamoDB attribute naming conventions (__source/__hmac). Use when adding encryption to a DynamoDB project, encrypting items before writes, decrypting items after reads, or querying encrypted DynamoDB attributes. +description: Integrate CipherStash encryption with Amazon DynamoDB using @cipherstash/stack/dynamodb and EQL v3 schemas. Covers item and bulk encryption, legacy v2 reads, HMAC query attributes, nested objects, audit logging, and the __source/__hmac storage convention. --- # CipherStash Stack - DynamoDB Integration @@ -42,42 +42,20 @@ Non-encrypted attributes pass through unchanged. On decryption, the `__source` a **Only equality is usable on DynamoDB.** Ordering terms and free-text bloom filters have no DynamoDB query surface, so they are not stored. A column in an ordering or free-text domain still encrypts and decrypts correctly — it just cannot back a key condition. -## Choosing a Schema Version +## Schema and Stored Versions -**Encrypt/write is EQL v3 only.** `encryptModel` / `bulkEncryptModels` accept only EQL v3 tables (`types.*` domains). **Decrypt still reads existing EQL v2 items** — `decryptModel` / `bulkDecryptModels` continue to accept an EQL v2 table so previously stored data stays readable. Author all new tables with EQL v3; keep a v2 table around only to read old data. +Schema authoring and every write are EQL v3-only. Existing v2 DynamoDB items +remain readable by passing the same v3 table descriptor plus +`{ storedEqlVersion: 2 }` to `decryptModel` or `bulkDecryptModels`. Both entries +serve legacy reads — the default `@cipherstash/stack` one and +`@cipherstash/stack/wasm-inline`. -| | EQL v3 (author + read) | EQL v2 (read existing data only) | -|---|---|---| -| Import | `@cipherstash/stack/v3` | `@cipherstash/stack` + `@cipherstash/stack/schema` | -| Schema | `encryptedTable` + `types.*` | `encryptedTable` + `encryptedColumn` | -| Client | `Encryption({ schemas })` (or the deprecated `EncryptionV3`) | `Encryption({ schemas })` | -| encrypt | ✅ | ❌ removed — write is v3 only | -| decrypt | ✅ | ✅ reads stored v2 items — **native entry only** | -| Nested fields | Flat dotted path (`"profile.ssn"`) | Nested group + `encryptedField` | - -> **v2 reads need the native entry.** A client from `@cipherstash/stack/wasm-inline` -> (the documented entry for Deno, Bun, Cloudflare Workers and Supabase Edge Functions) -> is EQL v3 only: `encryptedDynamoDB` throws at the call site if you pass it a v2 -> table. Read legacy v2 items with a client from the default `@cipherstash/stack` -> entry. EQL v3 tables work on both entries. - -There is no infrastructure migration between the versions — DynamoDB has no EQL extension to install and no schema to alter — and there is no automatic *data* migration either. The two use **different wire formats**; a stored v2 item still decrypts through a v2 table, but new writes are v3. To fully move a table to v3, re-encrypt every item with a v3 schema. +There is no infrastructure migration between the versions — DynamoDB has no EQL extension to install and no schema to alter — and there is no automatic *data* migration either. To fully move a table to v3, re-encrypt every item with the v3 schema. The client must be built for the table. Build the client with the same v3 table you hand to `encryptedDynamoDB` — `Encryption({ schemas: [users] })` returns the typed v3 client for a concrete v3 schema set. Passing a v3 table to a client that never registered it (a client built for a different schema set) throws a clear error naming the table on the first operation, instead of failing later with an opaque FFI deserialization error. -**Nested attributes work in both versions**, with different authoring syntax. - -DynamoDB items are natively nested, so this matters here more than on Postgres. Both versions encrypt *selected leaves in place*: the item keeps its shape and unlisted siblings stay plaintext. - -EQL v2 uses a nested group with `encryptedField`: - -```typescript -const users = encryptedTable("users", { - profile: { ssn: encryptedField("profile.ssn") }, -}) -``` - -EQL v3 has no nested-group syntax — a nested object in a v3 column map is a compile error. Declare the column **flat, with a dotted path** instead: +DynamoDB items are natively nested. Declare encrypted leaves as flat dotted +paths; the item keeps its nested shape and unlisted siblings stay plaintext: ```typescript const users = encryptedTable("users", { @@ -86,8 +64,6 @@ const users = encryptedTable("users", { }) ``` -Both produce the same nested DynamoDB attribute *layout*, but the encrypted contents are version-specific: an item written under one version cannot be decrypted under the other (see the compatibility note above). - ```jsonc { "pk": "u#1", "profile": { @@ -176,47 +152,29 @@ const dynamo = encryptedDynamoDB({ encryptionClient }) > **Audit metadata on decrypt works.** `decryptModel` / `bulkDecryptModels` are > audit-chainable — `dynamo.decryptModel(item, table).audit({ metadata })` forwards -> the metadata to ZeroKMS, whether the client came from `Encryption` or the -> deprecated `EncryptionV3` alias. (`EncryptionV3` is now a deprecated, type-identical -> alias of `Encryption`; prefer `Encryption`.) +> the metadata to ZeroKMS on the default `@cipherstash/stack` entry. The +> `@cipherstash/stack/wasm-inline` client has no chainable operations, so audit +> metadata is dropped there. -### EQL v2 Schema (reading existing deployments) +### Reading Existing EQL v2 Items -A v2 table is **read-only** through this adapter now: pass it to `decryptModel` / -`bulkDecryptModels` to read items written before the v3 cutover. `encryptModel` / -`bulkEncryptModels` no longer accept a v2 table — author new writes with an EQL v3 -table. +Use the current v3 table descriptor and select the stored wire version on the +read. No v2 builder or v2-configured client is needed. -**Not on the WASM entry.** This whole section requires a client from the default -`@cipherstash/stack` entry. `@cipherstash/stack/wasm-inline` is EQL v3 only — -`Encryption()` there rejects a v2 schema outright, and `encryptedDynamoDB` refuses -the pairing at the call site with a message naming the fix. So on Deno, Bun, -Workers and Supabase Edge Functions, legacy v2 items are not readable through this -adapter; re-encrypt them to a v3 schema, or read them from a Node process using the -native entry. +**Works on both entries.** Schema authoring is EQL v3-only everywhere, but the +read itself is not: the legacy path reconstructs the v2 envelope around the +current v3 table, and `decrypt` accepts either wire generation. So Deno, Bun, +Workers and Supabase Edge Functions can read legacy items through +`@cipherstash/stack/wasm-inline` too. ```typescript -import { encryptedTable, encryptedColumn, encryptedField } from "@cipherstash/stack/schema" -import { Encryption } from "@cipherstash/stack" - -const usersV2 = encryptedTable("users", { - email: encryptedColumn("email").equality(), // searchable via HMAC - name: encryptedColumn("name"), // encrypt-only, no search - metadata: encryptedColumn("metadata").dataType("json"), - profile: { // nested objects: v2 only - ssn: encryptedField("profile.ssn"), - }, -}) - -const encryptionClient = await Encryption({ schemas: [usersV2] }) -const dynamo = encryptedDynamoDB({ encryptionClient }) - -// Read existing v2 items — decrypt still accepts the v2 table. -const decrypted = await dynamo.decryptModel(storedV2Item, usersV2) +const decrypted = await dynamo.decryptModel( + storedV2Item, + users, + { storedEqlVersion: 2 }, +) ``` -> **Note:** `encryptedColumn` also supports `.orderAndRange()`, `.freeTextSearch()`, and `.searchableJson()` index methods, but only `.equality()` produces HMAC values usable for DynamoDB key condition queries. - ### Optional: Logger and Error Handler ```typescript @@ -480,7 +438,7 @@ if (result.failure) { import { encryptedDynamoDB } from "@cipherstash/stack/dynamodb" const dynamo = encryptedDynamoDB({ - // From Encryption(...) (or the deprecated EncryptionV3(...) alias) + // From Encryption(...) encryptionClient, options: { // optional logger: { error: (message, error) => void }, @@ -491,7 +449,8 @@ const dynamo = encryptedDynamoDB({ ### Instance Methods -**Encrypt accepts an EQL v3 table only. Decrypt accepts an EQL v3 OR an EQL v2 table** (so stored v2 items stay readable). The decrypt methods are overloaded on the table's EQL version. +All methods accept an EQL v3 table. Decrypt defaults to stored EQL v3 and can +reconstruct a legacy v2 envelope when explicitly requested. **EQL v3** — the item is checked against the table's column domains, and the result is typed as the attribute map that is actually stored: a declared column `email` becomes `email__source` (plus `email__hmac` if its domain mints one), NOT `email`. @@ -499,21 +458,43 @@ const dynamo = encryptedDynamoDB({ |---|---|---| | `encryptModel` | `(item, v3Table)` | `EncryptedAttributes<Table, T>` | | `bulkEncryptModels` | `(items, v3Table)` | `EncryptedAttributes<Table, T>[]` | -| `decryptModel` | `(storedItem, v3Table)` | `DecryptedAttributes<Table, T>` — `__source` folded back to the column, `__hmac` dropped | -| `bulkDecryptModels` | `(storedItems, v3Table)` | `DecryptedAttributes<Table, T>[]` | +| `decryptModel` | `(storedItem, v3Table, readOptions?)` | `DecryptedAttributes<Table, T>` — `__source` folded back to the column, `__hmac` dropped | +| `bulkDecryptModels` | `(storedItems, v3Table, readOptions?)` | `DecryptedAttributes<Table, T>[]` | Let `T` be inferred from the argument; do not pass explicit type arguments on the v3 path. -**EQL v2 — decrypt (read) only.** The v2 write overloads were removed; `encryptModel` / `bulkEncryptModels` no longer accept a v2 table. `decryptModel` / `bulkDecryptModels` still do, so existing v2 items decrypt. `T` is the model shape you expect back; declare a type of your own to read the stored `__source` / `__hmac` attributes. +For a stored v2 item, pass `{ storedEqlVersion: 2 }` as `readOptions`. The table +is still the current v3 descriptor, which supplies table and column identity. + +That descriptor must be one of the tables you passed to `Encryption({ schemas })`. +The adapter forwards it to the client to drive envelope and `Date` reconstruction, +and the client rejects a table it was not initialized with — so a legacy read of a +table your current schema no longer declares fails with `decryptModel received a +table this client was not initialized with`. Keep the table declared for as long +as you still need to read its v2 rows. | Method | Signature | Resolves to | |---|---|---| -| `decryptModel` | `(item: Record<string, unknown>, v2Table)` | `T` | -| `bulkDecryptModels` | `(items: Record<string, unknown>[], v2Table)` | `T[]` | +| `decryptModel` | `(item, v3Table, { storedEqlVersion: 2 })` | `T` | +| `bulkDecryptModels` | `(items, v3Table, { storedEqlVersion: 2 })` | `T[]` | + +**Grouped v2 fields.** A v2 column inside a group was stored as +`<group>.<leaf>__source` while the v2 schema knew it only as `<leaf>`. On a +`{ storedEqlVersion: 2 }` read the leaf is matched inside the group, so carrying +the column forward as a plain top-level `amount: types.TextEq('amount')` reads +those rows correctly. You can also name it by its full path, keeping the +original DB name — `'details.amount': types.TextEq('amount')`. Note the two +differ: the property is the dotted path, the argument is the v2 DB name. This +applies to v2 storage only; a v3 nested field uses the same dotted path for +both (`'profile.ssn': types.TextEq('profile.ssn')`). + +Type reconstruction follows either spelling: a grouped v2 `date` / `timestamp` +column comes back as a `Date`, not an ISO string, whether you declare it as a +plain top-level `placedAt: types.Date('placed_at')` or as the full dotted path. All operations are thenable (awaitable) and support `.audit({ metadata })` chaining. On the default `@cipherstash/stack` entry the metadata forwards to ZeroKMS on every operation, encrypt and decrypt alike (see the Setup note). The `@cipherstash/stack/wasm-inline` client has no `.audit()` — its operations return a plain promise — so audit metadata is **dropped** there (logged at debug level). The operation itself still succeeds; only the audit record is lost. Use the native entry when audit trails matter. -Types exported from `@cipherstash/stack/dynamodb`: `EncryptedDynamoDBInstance`, `EncryptedDynamoDBConfig`, `EncryptedDynamoDBError`, `AnyEncryptedTable`, `DynamoDBEncryptionClient`, `EncryptedAttributes`, `DecryptedAttributes`, `AuditConfig`. +Types exported from `@cipherstash/stack/dynamodb`: `EncryptedDynamoDBInstance`, `EncryptedDynamoDBConfig`, `EncryptedDynamoDBError`, `AnyEncryptedTable`, `DynamoDBReadOptions`, `DynamoDBEncryptionClient`, `EncryptedAttributes`, `DecryptedAttributes`, `AuditConfig`. ### Querying Encrypted Attributes diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 55c9112f0..39b1392a3 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -1,6 +1,6 @@ --- name: stash-encryption -description: Implement field-level encryption with @cipherstash/stack using the EQL v3 typed schema. Covers the types.* column catalog (concrete Postgres domains with fixed query capabilities), the strongly-typed Encryption client (with EncryptionV3 as a deprecated alias), encrypt/decrypt and model operations, searchable encryption (equality, free-text, range), encrypted JSON (containment and JSONPath selectors), bulk operations, identity-aware encryption with lock contexts, multi-tenant keysets, and the rollout/cutover lifecycle. Use when adding encryption to a project, defining encrypted schemas, or working with the CipherStash Encryption API. +description: Implement field-level encryption with @cipherstash/stack using the EQL v3 typed schema. Covers the types.* column catalog, the generic EncryptionClient, encrypt/decrypt and model operations, searchable encryption, encrypted JSON, bulk operations, identity-aware encryption, multi-tenant keysets, and the rollout/cutover lifecycle. --- # CipherStash Stack - Encryption @@ -182,17 +182,19 @@ The SDK never logs plaintext data. | Import Path | Provides | |---|---| -| `@cipherstash/stack/v3` | `Encryption` (the client factory), `EncryptionV3` (a deprecated alias of it), `typedClient`, `TypedEncryptionClient`, `EncryptionClientFor` — plus re-exports of everything in `@cipherstash/stack/eql/v3`. The one-stop import for v3 schema authoring. | +| `@cipherstash/stack/v3` | `Encryption`, `EncryptionClient<S>`, and the EQL v3 authoring DSL. The one-stop import for schema authoring. | | `@cipherstash/stack/eql/v3` | `encryptedTable`, the `types` namespace, `buildEncryptConfig`, inference types (`InferPlaintext`, `InferEncrypted`, `V3ModelInput`, ...) | -| `@cipherstash/stack` | `OidcFederationStrategy`, `AccessKeyStrategy`, the `Encryption` function (typed for an all-v3 schema set; nominal for v2/loose schemas), legacy v2 re-exports | +| `@cipherstash/stack` | `OidcFederationStrategy`, `AccessKeyStrategy`, and the v3-only `Encryption` factory | | `@cipherstash/stack/identity` | `LockContext` class and identity types | | `@cipherstash/stack/errors` | `EncryptionErrorTypes`, `StackError`, error subtypes, `getErrorMessage` | | `@cipherstash/stack/types` | All TypeScript types | | `@cipherstash/stack-drizzle` | Drizzle ORM integration for EQL v3 schemas — the package root, EQL v3 only (see the `stash-drizzle` skill) | | `@cipherstash/stack-supabase` | `encryptedSupabase` wrapper for Supabase — EQL v3 only (see the `stash-supabase` skill) | | `@cipherstash/stack/wasm-inline` | The **edge** entry — Deno, Bun, Cloudflare Workers, Supabase Edge Functions. Its own `Encryption` factory plus its own copy of the v3 authoring surface, `EncryptionErrorTypes`, and the WASM build of protect-ffi inlined into the bundle. No native binding, so no bundler externalisation needed. **EQL v3 only** — `Encryption()` here rejects a v2 schema, and its operations return plain Results with no `.audit()` or `.withLockContext()` chaining, so **values written here cannot be identity-bound** and it cannot read what the native entry wrote under a lock context. **ESM-only, and its schema types do not interchange with the other entries'** — see the `stash-edge` skill. | -| `@cipherstash/stack/dynamodb` | `encryptedDynamoDB` — encrypt/write is **EQL v3 only** (`types.*`); decrypt still reads existing v2 items, on the native entry only. See the `stash-dynamodb` skill | -| `@cipherstash/stack/schema`, `@cipherstash/stack/client`, `@cipherstash/stack/encryption` | Legacy v2 schema builders and client surface — see "Legacy: EQL v2" below | +| `@cipherstash/stack/dynamodb` | `encryptedDynamoDB` — encrypt/write is **EQL v3 only** (`types.*`); decrypt still reads existing v2 items via `{ storedEqlVersion: 2 }`, on both the native and `wasm-inline` entries. See the `stash-dynamodb` skill | +| `@cipherstash/stack/schema` | Low-level encrypt-config types and validation helpers; it is not a schema-authoring DSL | +| `@cipherstash/stack/encryption` | The `Encryption` factory and the chainable operation classes its methods return (`EncryptOperation`, `DecryptOperation`, `EncryptQueryOperation`, `BulkEncryptModelsOperation`, …). Import these only to *name* an operation's type; author schemas and build the client from `@cipherstash/stack/v3` | +| `@cipherstash/stack/adapter-kit` | The internal seam for the **first-party** adapter packages (`@cipherstash/stack-drizzle`, `@cipherstash/stack-supabase`). Not a general-purpose public API — anything an end user needs has a dedicated subpath above. Do not import it in application code | ## Schema Definition @@ -292,7 +294,7 @@ CREATE TABLE users ( ## Client Initialization: `Encryption` -`Encryption` from `@cipherstash/stack` is overloaded: hand it an array of concrete EQL v3 tables and it returns a `TypedEncryptionClient` whose method signatures are derived from your schemas — wrong-typed plaintext is rejected at compile time, and query methods only accept queryable columns with `queryType` constrained to the column's capabilities. `encryptedTable` / `types` are imported from `@cipherstash/stack/v3`: +`Encryption` from `@cipherstash/stack` accepts concrete EQL v3 tables and returns `EncryptionClient<S>`, whose method signatures are derived from those schemas. Wrong-typed plaintext is rejected at compile time, and query methods only accept queryable columns with `queryType` constrained to the column's capabilities: ```typescript import { Encryption } from "@cipherstash/stack" @@ -307,25 +309,23 @@ const users = encryptedTable("users", { const client = await Encryption({ schemas: [users] }) ``` -- The wire format is auto-detected as EQL v3 for an all-v3 schema set; you don't set it yourself. +- The wire format is always EQL v3; `config.eqlVersion` does not exist. - `Encryption()` throws on init error (bad credentials, missing config, invalid keyset UUID). At least one schema is required. -- `EncryptionV3` (from `@cipherstash/stack/v3`) is a **deprecated, type-identical alias** of `Encryption`, kept for backwards compatibility. New code should use `Encryption`. -- `typedClient(client, ...schemas)` (exported from `@cipherstash/stack/v3`) wraps an already-built untyped `EncryptionClient` in the typed surface, if you built one via a lower-level path. -- **v2 and v3 tables cannot be mixed in one client** — a mixed schema set throws at init. Create separate clients if you need both. +- A loose object or legacy v2 table is rejected at runtime as well as by TypeScript. - `schemas` takes any non-empty array of v3 tables — a shared `export const schemas: AnyV3Table[]`, a `ReadonlyArray`, one built at runtime. It does not have to be an array literal. Writing `Encryption({ schemas: [] })` is a compile error, but an array typed `AnyV3Table[]` that is empty at runtime compiles and throws on init instead. -- **To name the client's type, use `EncryptionClientFor<S>`** (from `@cipherstash/stack/v3`), not `Awaited<ReturnType<typeof Encryption>>`. `Encryption` is overloaded and `ReturnType` reads the last overload, so that idiom always resolves to the untyped nominal client: +- **To name the client's type, use `EncryptionClient<S>`** from `@cipherstash/stack/v3`: ```typescript -import { type AnyV3Table, Encryption, type EncryptionClientFor, encryptedTable, types } from "@cipherstash/stack/v3" +import { type AnyV3Table, Encryption, type EncryptionClient, encryptedTable, types } from "@cipherstash/stack/v3" const users = encryptedTable("users", { email: types.TextSearch("email") }) // A named schema tuple keeps per-column typing. -let client: EncryptionClientFor<readonly [typeof users]> +let client: EncryptionClient<readonly [typeof users]> client = await Encryption({ schemas: [users] }) // Code that is generic over its schemas keeps the typed surface too. -function withClient(c: EncryptionClientFor<readonly AnyV3Table[]>) { /* … */ } +function withClient(c: EncryptionClient<readonly AnyV3Table[]>) { /* … */ } ``` ```typescript @@ -981,7 +981,7 @@ Useful when the backfill needs to run in a worker, on a schedule, or alongside a ## Complete API Reference -### TypedEncryptionClient Methods +### EncryptionClient Methods | Method | Signature | Returns | |---|---|---| @@ -1009,20 +1009,12 @@ types.<Family><Suffix>(dbColumnName: string) ## Legacy: EQL v2 -Before the v3 typed schema, encrypted columns were a single composite type (`eql_v2_encrypted`) and query capabilities were enabled per column with chainable builders: - -```typescript -// Legacy v2 surface — for existing deployments only -import { Encryption } from "@cipherstash/stack" -import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema" - -const users = encryptedTable("users", { - email: encryptedColumn("email").equality().freeTextSearch().orderAndRange(), -}) - -const client = await Encryption({ schemas: [users] }) -``` +EQL v2 is a read-compatibility path, not a public authoring mode. The native +client's `decrypt`, `decryptModel`, `bulkDecrypt`, and `bulkDecryptModels` +recognise stored v2 payloads automatically; no v2 schema or `eqlVersion` flag is +required. The v2 builders and the `@cipherstash/stack/client` subpath have been +removed. Author every schema and every new write with EQL v3. **v2 is a read path now, not an authoring or rollout surface.** `decrypt` / `decryptModel` still read stored v2 payloads, but `stash` no longer installs EQL v2 or drives its Proxy configuration, backfill, rename, or drop lifecycle. For dump recovery, obtain the EQL 2.3.1 SQL from the upstream encrypt-query-language release. Migrate maintained deployments to v3 `types.*` domains. -> **DynamoDB.** The DynamoDB integration (`encryptedDynamoDB` from `@cipherstash/stack/dynamodb`) now **encrypts EQL v3 only** — author tables with `types.*` from `@cipherstash/stack/eql/v3`. Its decrypt methods still accept a v2 table so previously stored v2 items remain readable. See the `stash-dynamodb` skill. +> **DynamoDB.** The DynamoDB integration (`encryptedDynamoDB` from `@cipherstash/stack/dynamodb`) **encrypts EQL v3 only** — author tables with `types.*` from `@cipherstash/stack/eql/v3`. Legacy reads use the same v3 table descriptor plus an explicit `{ storedEqlVersion: 2 }` read option, so previously stored v2 items remain readable. See the `stash-dynamodb` skill. diff --git a/skills/stash-indexing/SKILL.md b/skills/stash-indexing/SKILL.md index d62efad76..08b803648 100644 --- a/skills/stash-indexing/SKILL.md +++ b/skills/stash-indexing/SKILL.md @@ -78,7 +78,7 @@ CREATE INDEX events_at_ord_ore ON events USING btree (eql_v3.ord_term_ore(encryp ANALYZE events; ``` -This one depends on a custom operator class the EQL installer must be privileged enough to create — see [Supabase and Managed Postgres](#supabase-and-managed-postgres-what-actually-needs-superuser) for which platforms allow it and for the failure mode to check. Prefer `types.TOrd` unless you specifically need ORE ordering on a platform whose installer could create the opclass. +This one depends on a custom operator class the EQL installer must be privileged enough to create — see [Supabase and Managed Postgres](#supabase-and-managed-postgres-what-actually-needs-superuser) for which platforms allow it and for the failure mode to check. Prefer `types.NOrd` / `types.TextOrd` unless you specifically need ORE ordering on a platform whose installer could create the opclass. ### Free-Text Match — `eql_v3.match_term`