From 3aff6cb6f09b6b7fdabfb78ff853fa543a2bd23a Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 29 Jul 2026 12:35:44 +1000 Subject: [PATCH 01/11] feat(stack): make encryption authoring v3-only --- .changeset/adapter-package-split.md | 13 +- .changeset/drizzle-kit-eql-v3-ddl.md | 6 +- .changeset/dynamodb-eql-v3.md | 13 +- .changeset/dynamodb-wasm-v2-read.md | 25 +- .changeset/encryption-schema-arrays.md | 10 +- .changeset/eql-v3-drizzle-encrypt-query.md | 7 +- .changeset/eql-v3-drizzle.md | 6 +- .changeset/eql-v3-ga-rebaseline.md | 11 +- .changeset/eql-v3-prisma-next.md | 5 +- .changeset/eql-v3-sole-docs.md | 10 +- .changeset/eql-v3-supabase-adapter.md | 5 +- .changeset/eql-v3-typed-client.md | 15 +- .changeset/eql-v3-wasm-inline.md | 9 +- .changeset/init-drizzle-eql-v3.md | 4 +- .changeset/init-placeholder-eql-v3.md | 16 +- .changeset/init-scaffold-compiles.md | 5 +- .changeset/migrate-eql-v3.md | 2 +- .../prisma-next-drop-encrypted-prefix.md | 8 +- .changeset/prisma-next-v3-client-config.md | 19 +- .changeset/protect-ffi-030-json-selectors.md | 15 +- .changeset/reject-v2-wire-over-v3-schemas.md | 35 +- .changeset/remove-eql-v2-packages.md | 3 +- .../remove-eql-v2-scaffold-examples-meta.md | 14 +- .../remove-eql-v2-supabase-authoring.md | 8 +- .changeset/skills-encryption-client-naming.md | 5 +- .changeset/stack-audit-on-decrypt.md | 32 +- .changeset/stack-dynamodb-v2-write-removal.md | 12 +- .changeset/stack-skills-eql-v3-audit.md | 11 +- .changeset/supabase-v2-table-diagnosis.md | 22 +- .changeset/typed-client-init-parity.md | 33 +- .changeset/v3-only-encryption-client.md | 15 + AGENTS.md | 6 +- packages/bench/src/drizzle/setup.ts | 8 +- .../init/__tests__/utils-codegen.test.ts | 2 +- .../__tests__/backfill-v3.integration.test.ts | 2 +- packages/prisma-next/DEVELOPING.md | 8 +- packages/prisma-next/src/exports/stack.ts | 2 +- .../prisma-next/src/stack/from-stack-v3.ts | 16 +- .../prisma-next/src/v3/derive-schemas-v3.ts | 4 +- packages/prisma-next/src/v3/sdk-adapter-v3.ts | 6 +- .../test/live/bulk-encrypt-live-pg.test.ts | 2 +- .../prisma-next/test/live/helpers/harness.ts | 2 +- .../prisma-next/test/v3/from-stack-v3.test.ts | 2 +- packages/prisma-next/tsconfig.json | 1 - .../__tests__/operators.test-d.ts | 13 +- packages/stack-drizzle/integration/adapter.ts | 8 +- .../stack-drizzle/integration/json-adapter.ts | 8 +- .../lock-context.integration.test.ts | 8 +- .../null-persistence.integration.test.ts | 6 +- .../relational.integration.test.ts | 8 +- packages/stack-drizzle/src/operators.ts | 8 +- .../__tests__/column-map-predicate.test.ts | 5 +- .../__tests__/supabase-schema-builder.test.ts | 12 +- .../__tests__/supabase-v3-factory.test.ts | 25 +- .../__tests__/supabase-v3.test-d.ts | 11 +- packages/stack-supabase/src/column-map.ts | 11 +- packages/stack-supabase/src/index.ts | 2 +- packages/stack-supabase/src/query-encrypt.ts | 41 +- packages/stack-supabase/src/query-mutation.ts | 9 +- packages/stack-supabase/src/types.ts | 7 +- packages/stack/README.md | 46 +- packages/stack/__tests__/audit.test.ts | 8 +- .../stack/__tests__/backward-compat.test.ts | 56 - .../stack/__tests__/basic-protect.test.ts | 8 +- .../bulk-model-hyphen-fields.test.ts | 8 +- packages/stack/__tests__/bulk-protect.test.ts | 6 +- .../decrypt-audit-forwarding.test.ts | 4 +- .../dynamodb/client-compat.test-d.ts | 432 +------ .../dynamodb/construct-guard.test.ts | 5 +- .../dynamodb/encrypted-dynamodb-v3.test.ts | 17 +- .../dynamodb/encrypted-dynamodb.test.ts | 420 ------ .../__tests__/dynamodb/helpers-v3.test.ts | 52 +- .../stack/__tests__/dynamodb/helpers.test.ts | 349 ----- .../__tests__/dynamodb/properties.test.ts | 51 +- .../dynamodb/v2-table-forwarding.test.ts | 368 +----- .../__tests__/empty-schemas-boundary.test.ts | 11 +- .../encrypt-lock-context-guards.test.ts | 15 +- .../encrypt-query-match-preflight.test.ts | 6 +- .../__tests__/encryption-overloads.test-d.ts | 248 ---- .../__tests__/encryption-v3-only.test-d.ts | 51 + packages/stack/__tests__/error-codes.test.ts | 20 +- packages/stack/__tests__/fixtures/index.ts | 26 +- .../stack/__tests__/infer-index-type.test.ts | 2 +- .../stack/__tests__/init-strategy.test.ts | 107 +- packages/stack/__tests__/json-protect.test.ts | 1150 ----------------- packages/stack/__tests__/keysets.test.ts | 4 +- .../__tests__/lock-context-wiring.test.ts | 4 +- packages/stack/__tests__/lock-context.test.ts | 6 +- .../__tests__/match-needle-guard.test.ts | 2 +- .../__tests__/model-column-mapping.test.ts | 5 +- .../stack/__tests__/nested-models.test.ts | 959 -------------- packages/stack/__tests__/null-guards.test.ts | 4 +- .../stack/__tests__/number-protect.test.ts | 760 ----------- packages/stack/__tests__/protect-ops.test.ts | 6 +- .../__tests__/resolve-eql-version.test.ts | 137 -- .../stack/__tests__/schema-builders.test.ts | 395 ------ packages/stack/__tests__/schema-v3.test-d.ts | 370 ------ packages/stack/__tests__/schema-v3.test.ts | 2 +- .../typed-client-init-wire-version.test.ts | 96 -- .../typed-client-nominal-parity.test.ts | 82 -- .../stack/__tests__/typed-client-v3.test-d.ts | 45 +- .../stack/__tests__/typed-client-v3.test.ts | 36 +- .../__tests__/types-public-surface.test-d.ts | 42 - packages/stack/__tests__/types.test-d.ts | 171 --- .../v3-matrix/matrix-audit.test-d.ts | 5 +- .../v3-matrix/matrix-lock-context.test.ts | 6 +- .../__tests__/v3-only-public-surface.test.ts | 21 + .../__tests__/wasm-inline-column-name.test.ts | 9 - .../stack/__tests__/wasm-inline-v3.test.ts | 13 +- .../matrix-identity.integration.test.ts | 12 +- .../shared/json-crypto.integration.test.ts | 8 +- .../shared/match-bloom.integration.test.ts | 14 +- .../shared/matrix-bulk.integration.test.ts | 8 +- .../shared/matrix-crypto.integration.test.ts | 8 +- .../shared/matrix-keyset.integration.test.ts | 10 +- .../shared/matrix-sql.integration.test.ts | 8 +- .../shared/ope-term.integration.test.ts | 4 +- .../shared/schema-pg.integration.test.ts | 11 +- .../schema-v3-client.integration.test.ts | 4 +- .../v2-decrypt-compat.integration.test.ts | 236 +--- packages/stack/package.json | 13 - packages/stack/src/client.ts | 23 - packages/stack/src/dynamodb/helpers.ts | 47 +- packages/stack/src/dynamodb/index.ts | 72 +- .../operations/bulk-decrypt-models.ts | 16 +- .../operations/bulk-encrypt-models.ts | 5 +- .../src/dynamodb/operations/decrypt-model.ts | 25 +- .../src/dynamodb/operations/encrypt-model.ts | 7 +- packages/stack/src/dynamodb/types.ts | 65 +- packages/stack/src/encryption/client-v3.ts | 442 +++++++ packages/stack/src/encryption/index.ts | 223 +--- packages/stack/src/encryption/v3.ts | 494 +------ packages/stack/src/eql/v3/table.ts | 2 +- packages/stack/src/index.ts | 2 +- packages/stack/src/schema/index.ts | 778 +---------- packages/stack/src/schema/internal.ts | 754 +++++++++++ packages/stack/src/types-public.ts | 9 - packages/stack/src/types.ts | 59 +- packages/stack/src/wasm-inline.ts | 51 +- packages/stack/tsup.config.ts | 1 - packages/stack/vitest.config.ts | 2 +- skills/stash-dynamodb/SKILL.md | 95 +- skills/stash-encryption/SKILL.md | 46 +- 143 files changed, 2213 insertions(+), 8624 deletions(-) create mode 100644 .changeset/v3-only-encryption-client.md delete mode 100644 packages/stack/__tests__/backward-compat.test.ts delete mode 100644 packages/stack/__tests__/dynamodb/encrypted-dynamodb.test.ts delete mode 100644 packages/stack/__tests__/dynamodb/helpers.test.ts delete mode 100644 packages/stack/__tests__/encryption-overloads.test-d.ts create mode 100644 packages/stack/__tests__/encryption-v3-only.test-d.ts delete mode 100644 packages/stack/__tests__/json-protect.test.ts delete mode 100644 packages/stack/__tests__/nested-models.test.ts delete mode 100644 packages/stack/__tests__/number-protect.test.ts delete mode 100644 packages/stack/__tests__/resolve-eql-version.test.ts delete mode 100644 packages/stack/__tests__/schema-builders.test.ts delete mode 100644 packages/stack/__tests__/schema-v3.test-d.ts delete mode 100644 packages/stack/__tests__/typed-client-init-wire-version.test.ts delete mode 100644 packages/stack/__tests__/typed-client-nominal-parity.test.ts delete mode 100644 packages/stack/__tests__/types-public-surface.test-d.ts delete mode 100644 packages/stack/__tests__/types.test-d.ts create mode 100644 packages/stack/__tests__/v3-only-public-surface.test.ts delete mode 100644 packages/stack/src/client.ts create mode 100644 packages/stack/src/encryption/client-v3.ts create mode 100644 packages/stack/src/schema/internal.ts 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/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-wasm-v2-read.md b/.changeset/dynamodb-wasm-v2-read.md index b75673051..ba0a2ac94 100644 --- a/.changeset/dynamodb-wasm-v2-read.md +++ b/.changeset/dynamodb-wasm-v2-read.md @@ -2,24 +2,17 @@ '@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 refuses a `@cipherstash/stack/wasm-inline` client when a +legacy read is requested with `{ storedEqlVersion: 2 }`, instead of failing at +the first read with a misleading error. -The adapter's v2 read path deliberately calls `decryptModel(item)` with **no** -table — a v2 table means nothing to a v3 client's reconstructor map, and the -native clients derive the table from the payloads anyway. `WasmEncryptionClient` -cannot do that: its decrypt requires the table and resolves date fields from a -per-table map, so the omitted argument surfaced as -`TypeError: Cannot read properties of undefined (reading 'tableName')` thrown -from deep inside the client — on the documented entry for Deno, Cloudflare -Workers and Supabase Edge Functions, which satisfies the adapter's client type -structurally and so was accepted with no cast. +The adapter's native v2 read path relies on generation-agnostic payload decrypt. +`WasmEncryptionClient` cannot perform that compatibility read, so the former +path surfaced a deep client error on Deno, Cloudflare Workers and Supabase Edge +Functions rather than explaining the unsupported combination. -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. +The pairing is now rejected at the call site, with a message directing callers +to the native entry for legacy reads. 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..00cc71720 100644 --- a/.changeset/encryption-schema-arrays.md +++ b/.changeset/encryption-schema-arrays.md @@ -27,10 +27,10 @@ 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` and keep the typed surface. If you narrowed a schema array to `readonly [AnyV3Table, ...AnyV3Table[]]` to satisfy the old signature, that narrowing is no longer needed. @@ -48,5 +48,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/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-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/AGENTS.md b/AGENTS.md index 2ce2f337b..49c9dec06 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. The native client still decrypts existing v2 payloads. DynamoDB legacy reads use the same v3 table descriptor plus `{ storedEqlVersion: 2 }`; 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/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..d2606339b 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,13 @@ 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 + * `ClientConfig`, not `ClientConfig`: this package is EQL v3 only, and the * legacy `eqlVersion: 2` escape hatch throws at setup over the all-v3 schema * set this entry point derives. */ - readonly encryptionConfig?: V3ClientConfig + readonly encryptionConfig?: ClientConfig } export interface CipherstashFromStackV3Result { @@ -71,10 +71,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 +100,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..37e1e347c 100644 --- a/packages/stack-drizzle/__tests__/operators.test-d.ts +++ b/packages/stack-drizzle/__tests__/operators.test-d.ts @@ -1,17 +1,16 @@ import type { Result } from '@byteslice/result' import type { AuditConfig } from '@cipherstash/stack/adapter-kit' -import type { EncryptionClient } from '@cipherstash/stack/encryption' import type { EncryptionError } from '@cipherstash/stack/errors' import type { LockContext } from '@cipherstash/stack/identity' import type { EncryptedQueryResult } from '@cipherstash/stack/types' -import type { AnyV3Table, EncryptionClientFor } from '@cipherstash/stack/v3' +import type { AnyV3Table, 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 + * `EncryptionClient` that `Encryption` resolves to — the documented + * `createEncryptionOperators(await Encryption({ 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 @@ -21,7 +20,7 @@ import { createEncryptionOperators } from '../src/index.js' * existing typecheck scope without dragging the loose-typed runtime suites in. */ describe('createEncryptionOperators - client parameter (M1)', () => { - type V3Client = EncryptionClientFor + type V3Client = EncryptionClient // A query operation resolving `Result` — the surface the factory drives. type QueryOp = { @@ -30,11 +29,11 @@ describe('createEncryptionOperators - client parameter (M1)', () => { then: PromiseLike>['then'] } - it('accepts the client EncryptionV3 returns with no cast', () => { + it('accepts the client Encryption returns with no cast', () => { expectTypeOf(createEncryptionOperators).toBeCallableWith({} as V3Client) }) - it('still accepts the nominal EncryptionClient', () => { + it('accepts the generic EncryptionClient', () => { expectTypeOf(createEncryptionOperators).toBeCallableWith( {} 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..6f5666c25 100644 --- a/packages/stack-drizzle/src/operators.ts +++ b/packages/stack-drizzle/src/operators.ts @@ -47,9 +47,9 @@ 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 + * `EncryptionClient`, by the `EncryptionClient` that `Encryption` returns * (whatever its schema tuple), AND by a hand-rolled test double, none needing a - * cast. Typing the parameter to the nominal `TypedEncryptionClient` would + * cast. Typing the parameter to the nominal `EncryptionClient` would * reject a client built for a narrower schema tuple (it accepts fewer tables than * `readonly AnyV3Table[]`); the structural surface sidesteps that variance. * @@ -182,14 +182,14 @@ type ChainableOperation = { * (e.g. ordering a non-`ore` column). * * @param client - anything that can `encryptQuery` — the nominal - * `EncryptionClient` or the `TypedEncryptionClient` from `EncryptionV3` (no + * `EncryptionClient` or the `EncryptionClient` from `Encryption` (no * cast needed). * @param defaults - lock context / audit applied to every operand encryption * unless a per-call override is supplied. * * @example * ```typescript - * const ops = createEncryptionOperators(await EncryptionV3({ schemas: [users] })) + * 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..6940fc06d 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' @@ -119,8 +118,8 @@ describe('isV3ColumnLike', () => { // 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() + 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..c69d10cb2 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,10 @@ describe('encryptedSupabaseV3 typed surface (with schemas)', () => { }) it('rejects a v2 table in schemas', async () => { - const v2Table = v2EncryptedTable('users', { - email: encryptedColumn('email').equality(), - }) + const v2Table = { + tableName: 'users', + 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-encrypt.ts b/packages/stack-supabase/src/query-encrypt.ts index 6d617ae10..a12fee6f2 100644 --- a/packages/stack-supabase/src/query-encrypt.ts +++ b/packages/stack-supabase/src/query-encrypt.ts @@ -59,6 +59,14 @@ export type EncryptedFilterState = { termMap: TermMapping[] } +type CollectedQueryTerm = { + value: ScalarQueryTerm['value'] + column: V3ColumnLike + table: AnyV3Table + queryType?: QueryTypeName + returnType?: ScalarQueryTerm['returnType'] +} + /** * Everything an encryption step needs from the builder. Assembled per * `execute()`, so `lockContext`/`auditConfig` are read at execution time — not @@ -75,6 +83,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. * @@ -251,7 +279,7 @@ export async function encryptFilterValues( ctx: EncryptionContext, ): Promise { // Collect all terms that need encryption - const terms: ScalarQueryTerm[] = [] + const terms: CollectedQueryTerm[] = [] const termMap: TermMapping[] = [] const tableColumns = ctx.columns.queryColumnMap() @@ -259,7 +287,7 @@ export async function encryptFilterValues( const pushTerm = ( value: JsPlaintext, - column: ScalarQueryTerm['column'], + column: V3ColumnLike, queryType: QueryTypeName, mapping: TermMapping, ) => { @@ -288,7 +316,7 @@ export async function encryptFilterValues( const collectInListTerms = ( op: FilterOp, values: readonly unknown[], - column: ScalarQueryTerm['column'], + column: V3ColumnLike, queryType: QueryTypeName, mappingFor: (inIndex: number) => TermMapping, ) => { @@ -473,7 +501,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< @@ -524,7 +552,7 @@ async function encryptCollectedTerms( * 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 +687,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/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..8fff3bd0b 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -691,12 +691,12 @@ if (result.failure) { function Encryption(config: { schemas: AnyV3Table[] config?: ClientConfig -}): Promise +}): 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. +The wire format is pinned to EQL v3 — `config.eqlVersion` is not supported. -### `TypedEncryptionClient` Methods +### `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 +751,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 +768,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 +780,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 +789,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..1d6d861d4 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 () => { 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..0f3ec35fe 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) }) 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-table-forwarding.test.ts b/packages/stack/__tests__/dynamodb/v2-table-forwarding.test.ts index d13ad10b7..2a29c10a0 100644 --- a/packages/stack/__tests__/dynamodb/v2-table-forwarding.test.ts +++ b/packages/stack/__tests__/dynamodb/v2-table-forwarding.test.ts @@ -1,350 +1,100 @@ -/** - * Which table (if any) the DynamoDB adapter forwards to the encryption client. - * - * `encryptedDynamoDB` promises that `decryptModel` / `bulkDecryptModels` keep - * accepting an EQL **v2** table so previously stored v2 items stay readable - * (see the contract note in `src/dynamodb/index.ts`). That promise breaks if the - * adapter forwards the v2 table to a v3-configured client: the typed client - * looks the table up in its own reconstructor map, does not find it, and fails - * with "decryptModel received a table this client was not initialized with". - * - * The nominal client derives the table from the payloads and needs no second - * argument, and the typed client now exposes a table-less overload for exactly - * this case — so the correct forward is conditional on the table's generation, - * not unconditional. - * - * Credential-free by construction: the adapter never touches the AWS SDK, and - * these drive it with a recording stub client, so the assertion is on the call - * the adapter makes rather than on a live decrypt. That matters because the only - * other coverage of this path is an integration suite requiring live ZeroKMS. - */ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { 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' +import { encryptedTable, types } from '@/eql/v3' -// The audit-drop tests spy on the shared `logger` singleton, which other suites -// in this directory also patch. Each restores its own spy in a `finally`; this -// is the safety net so a patched method can never survive a test boundary. -afterEach(() => { - vi.restoreAllMocks() -}) - -const usersV2 = encryptedTableV2('users_v2', { - email: encryptedColumn('email').equality(), -}) - -const usersV3 = encryptedTableV3('users_v3', { +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. - */ -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: {} }) + 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 = { + it('rejects legacy reads for clients that require table-aware decrypt', () => { + 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 }) - 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(() => + dynamo.decryptModel({ email__source: 'ciphertext' }, users, { + storedEqlVersion: 2, + }), + ).toThrow(/wasm-inline client cannot read.*stored from EQL v2/) expect(calls).toHaveLength(0) }) - // v3 tables ARE forwarded the table, so this client works there — the guard - // must not turn into a blanket rejection of the wasm entry. - it('is accepted for an EQL v3 table, which is always given the table', async () => { - const { calls, client } = wasmShapedClient(['users_v3']) + it('rejects an invalid stored version from JavaScript callers', () => { + const { client } = recordingClient() const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) - await dynamo.decryptModel({ pk: 'a' }, usersV3) - - expect(calls).toHaveLength(1) - expect(calls[0]?.argCount).toBe(2) - }) - - /** - * #788 review follow-up: the same "v3 tables are unaffected" promise, on the - * WRITE path. - * - * The encrypt operations chained `.audit()` onto the client's result - * unconditionally. The native clients return a thenable operation carrying - * it; `WasmEncryptionClient.encryptModel` returns a plain - * `Promise` from `wasmResult` (it has no `.audit()` anywhere), - * so every v3 encrypt through this adapter died with - * `client.encryptModel(...).audit is not a function` — surfaced as a - * `DYNAMODB_ENCRYPTION_ERROR` failure, not a crash, so it read as a genuine - * encryption fault. The decrypt path already tolerates the bare promise via - * `resolveDecryptResult`; the write path must match. - */ - it('encrypts an EQL v3 table even though its encrypt returns a bare promise', async () => { - const { calls, client } = wasmShapedClient(['users_v3']) - const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) - - const single = await dynamo.encryptModel({ pk: 'a' } as never, usersV3) - expect(single.failure).toBeUndefined() - - const bulk = await dynamo.bulkEncryptModels([{ pk: 'a' }] as never, usersV3) - expect(bulk.failure).toBeUndefined() - - expect(calls.map((c) => c.method)).toEqual([ - 'encryptModel', - 'bulkEncryptModels', - ]) - }) - - /** - * Audit metadata has nowhere to go on this client shape, so it is dropped — - * but the encrypt must still succeed rather than failing the whole write, and - * the drop must be OBSERVABLE rather than silent. Asserting only that the - * result succeeded would pass even with the debug log deleted, and that log is - * the half that makes a missing audit record diagnosable. - */ - it('drops audit metadata observably, without failing the encrypt', async () => { - const spy = vi.spyOn(logger, 'debug').mockImplementation(() => {}) - - try { - const { client } = wasmShapedClient(['users_v3']) - const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) - - const result = await dynamo - .encryptModel({ pk: 'a' } as never, usersV3) - .audit({ metadata: { requestId: 'r-1' } }) - - expect(result.failure).toBeUndefined() - expect(spy).toHaveBeenCalledWith( - expect.stringContaining('encryptModel audit metadata ignored'), - ) - // It must name the entry to switch to, not merely report a loss. - expect(spy.mock.calls.at(-1)?.[0]).toMatch(/@cipherstash\/stack/) - } finally { - spy.mockRestore() - } - }) - - /** - * The mirror: a client that DOES carry `.audit()` must not trip the drop path. - * Without this, a "fix" that logged unconditionally — or that stopped chaining - * `.audit()` at all — would leave every test green while silently discarding - * the native clients' audit trail. - */ - it('does not report a drop when the client can carry the metadata', async () => { - const spy = vi.spyOn(logger, 'debug').mockImplementation(() => {}) - - try { - let seen: unknown - const chainable = { - audit(config: { metadata?: Record }) { - seen = config.metadata - return Promise.resolve({ data: {} }) - }, - } - const client = { - getEncryptConfig: () => ({ v: 1, tables: { users_v3: {} } }), - encryptModel: () => chainable, - bulkEncryptModels: () => chainable, - decryptModel: () => Promise.resolve({ data: {} }), - bulkDecryptModels: () => Promise.resolve({ data: {} }), - } - const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) - - const result = await dynamo - .encryptModel({ pk: 'a' } as never, usersV3) - .audit({ metadata: { requestId: 'r-1' } }) - - expect(result.failure).toBeUndefined() - expect(seen).toEqual({ requestId: 'r-1' }) - expect(spy).not.toHaveBeenCalledWith( - expect.stringContaining('audit metadata ignored'), - ) - } finally { - spy.mockRestore() - } - }) - - /** - * The tolerance must not swallow a malformed result into a fake success — - * the same guard `resolveDecryptResult` applies on read. - */ - it('rejects a bare encrypt result that is not { data } or { failure }', async () => { - const client = { - requiresTableForDecrypt: true, - getEncryptConfig: () => ({ v: 1, tables: { users_v3: {} } }), - encryptModel: () => Promise.resolve('not-a-result'), - bulkEncryptModels: () => Promise.resolve('not-a-result'), - decryptModel: () => Promise.resolve({ data: {} }), - bulkDecryptModels: () => Promise.resolve({ data: {} }), - } - const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) - - const result = await dynamo.encryptModel({ pk: 'a' } as never, usersV3) - - expect(result.failure?.message).toMatch(/malformed result/) + expect(() => + dynamo.decryptModel({ email__source: 'ciphertext' }, users, { + storedEqlVersion: 4, + } as never), + ).toThrow(/unsupported storedEqlVersion 4/) }) }) diff --git a/packages/stack/__tests__/empty-schemas-boundary.test.ts b/packages/stack/__tests__/empty-schemas-boundary.test.ts index 7d67ab2b2..cbbbf2d4f 100644 --- a/packages/stack/__tests__/empty-schemas-boundary.test.ts +++ b/packages/stack/__tests__/empty-schemas-boundary.test.ts @@ -19,9 +19,8 @@ */ 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 +68,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 +87,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__/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..2e06ca15e --- /dev/null +++ b/packages/stack/__tests__/encryption-v3-only.test-d.ts @@ -0,0 +1,51 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { Encryption, type EncryptionClient } from '@/encryption' +import { type AnyV3Table, encryptedTable, types } from '@/eql/v3' +import type { ClientConfig } 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 + >() + }) +}) 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/index.ts b/packages/stack/__tests__/fixtures/index.ts index 16e19bc74..2568a22a9 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,48 @@ 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) */ export const articles = encryptedTable('articles', { - content: encryptedColumn('content').freeTextSearch(), + content: types.TextSearch('content'), }) /** * Products table with only orderAndRange (for auto-inference test) */ export const products = encryptedTable('products', { - price: encryptedColumn('price').dataType('number').orderAndRange(), + price: types.IntegerOrd('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 ============ 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..450e85319 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,107 +180,35 @@ 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('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/, - ) - }) - - it('throws on a mixed schema set even with an explicit eqlVersion', 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] }) - - expect(lastNewClientOpts().eqlVersion).toBe(3) - }) - - 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. + it('rejects the removed config.eqlVersion escape hatch at runtime', async () => { await expect( - EncryptionV3({ - schemas: [v3Table() as never], - config: { eqlVersion: 2 }, + Encryption({ + schemas: [users], + config: { eqlVersion: 2 } as never, }), - ).rejects.toThrow(/entirely EQL v3/) + ).rejects.toThrow(/config\.eqlVersion.*removed/) expect(ffi.newClient).not.toHaveBeenCalled() }) 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__/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..a446ede03 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', () => { diff --git a/packages/stack/__tests__/typed-client-v3.test.ts b/packages/stack/__tests__/typed-client-v3.test.ts index 3497021ec..e3eab54ac 100644 --- a/packages/stack/__tests__/typed-client-v3.test.ts +++ b/packages/stack/__tests__/typed-client-v3.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import type { EncryptionClient } from '@/encryption' -import { encryptedTable, typedClient, types } from '@/encryption/v3' +import { + createEncryptionClient, + encryptedTable, + types, +} from '@/encryption/client-v3' const table = encryptedTable('t', { when: types.Timestamp('when'), @@ -11,7 +15,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 +44,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 +72,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 +86,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 +109,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 +125,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 +146,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 +174,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 +194,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 +218,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 deleted file mode 100644 index 7bd458908..000000000 --- a/packages/stack/__tests__/types-public-surface.test-d.ts +++ /dev/null @@ -1,42 +0,0 @@ -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, - Encrypted, - EncryptedFromBuildableTable, -} from '@/types-public' - -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('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> - - // Schema-column fields become Encrypted; passthrough fields keep their type. - expectTypeOf<Enc['email']>().toEqualTypeOf<Encrypted>() - expectTypeOf<Enc['id']>().toEqualTypeOf<number>() - - // The column-map helper is nameable too. - expectTypeOf<keyof BuildableTableColumns<Users>>().toEqualTypeOf<'email'>() - }) -}) 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..3dad16fca --- /dev/null +++ b/packages/stack/__tests__/v3-only-public-surface.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import * as v3 from '@/encryption/v3' +import * as root from '@/index' +import * as schema from '@/schema' +import packageJson from '../package.json' + +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') + 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') + }) +}) 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-v3.test.ts b/packages/stack/__tests__/wasm-inline-v3.test.ts index 67329827f..86d862908 100644 --- a/packages/stack/__tests__/wasm-inline-v3.test.ts +++ b/packages/stack/__tests__/wasm-inline-v3.test.ts @@ -32,12 +32,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 +69,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. 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..bc9788413 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,101 @@ /** - * 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: - * - * - #1a: a v2 ciphertext + a v2 model decrypt through the collapsed `Encryption` - * client's `decrypt` / `decryptModel`. - * - #1b: a stored v2 DynamoDB item — split with the exported - * `toEncryptedDynamoItem(payload, attrs, false)` — decrypts through - * `encryptedDynamoDB(...).decryptModel(item, v2Table)`, exercising the v2 - * envelope reconstruction (`toItemWithEqlPayloads`, `v === 2` / `k: 'ct'`). - * - #1c: the SAME v2 payloads decrypt through a **v3-configured** client that - * has never heard of the v2 table. - * - * #1c is the invariant customers actually depend on and the reason this file - * cannot be a v2-only suite: after the removal their client is v3, their stored - * data is v2, and #1a/#1b would keep passing even if a v3 client had lost the - * ability to read v2 — because both mint AND read through the v2-mode client. - * Whatever eventually deletes the `eqlVersion: 2` minting path must keep #1c - * alive (against a checked-in ciphertext fixture, or a raw `EncryptConfig`), - * not delete it along with the escape hatch. - * - * Live: requires real ZeroKMS credentials (the integration harness provisions - * them and throws otherwise). The credential-free half of the v2 read path — the - * DynamoDB envelope split/reconstruction over static payloads — is covered by - * `__tests__/dynamodb/helpers.test.ts`, which needs no network at all. + * 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. */ +import { + encrypt as ffiEncrypt, + encryptBulk as ffiEncryptBulk, + newClient as newFfiClient, +} from '@cipherstash/protect-ffi' import { unwrapResult } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' import { encryptedDynamoDB } from '@/dynamodb' import { toEncryptedDynamoItem } from '@/dynamodb/helpers' -import type { EncryptionClient } from '@/encryption' -import { encryptedTable as encryptedTableV3, types as typesV3 } from '@/eql/v3' +import { 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' - -// 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(), -}) +import type { Encrypted } from '@/types' -// A DIFFERENT table, in v3 builders, so the v3 client below carries no knowledge -// of the v2 table whatsoever — reading v2 data must not depend on the v2 schema -// still being registered. -const unrelatedV3 = encryptedTableV3('v2_read_compat_unrelated_v3', { - note: typesV3.TextEq('note'), +const users = encryptedTable('v2_read_compat_users', { + email: types.TextEq('email'), }) const SECRET = 'ada@example.com' +let fixtureClient: Awaited<ReturnType<typeof newFfiClient>> +let client: Awaited<ReturnType<typeof makeClient>> -// 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] }) 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() }) -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 +} + +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 }) - const decrypted = unwrapResult(await v2Client.decrypt(encrypted)) - expect(decrypted).toBe(SECRET) + 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), - ) - const storedV2Item = toEncryptedDynamoItem(encryptedModel, ['email'], false) - // The email column becomes `email__source` (+ `email__hmac` for equality); - // the plaintext key does not survive the split. - expect(storedV2Item).toHaveProperty('email__source') - expect(storedV2Item).not.toHaveProperty('email') - expect(storedV2Item.pk).toBe('a') + it('bulk-decrypts v2 ciphertexts', async () => { + const encrypted = (await ffiEncryptBulk(fixtureClient, { + plaintexts: [ + { id: '1', plaintext: SECRET, table: users.tableName, column: 'email' }, + { + id: '2', + plaintext: 'grace@example.com', + table: users.tableName, + column: 'email', + }, + ], + })) as Encrypted[] - const dynamo = encryptedDynamoDB({ encryptionClient: v2Client }) const decrypted = unwrapResult( - await dynamo.decryptModel(storedV2Item, usersV2), - ) - - expect(decrypted).toMatchObject({ pk: 'a', email: SECRET }) - }, 30000) -}) - -// The real-world shape of the compatibility promise: the customer upgraded, so -// their client is v3-configured and knows nothing about the v2 table — but the -// rows already in their database are v2. -describe('#1c — a v3-configured client reads stored EQL v2 payloads', () => { - // The precondition every other case in this block rests on. Without it the - // whole describe can pass vacuously: if v3 detection regressed, - // `resolveEqlVersion` would return `undefined` rather than throw, leaving - // `v3Client` on the FFI's v2 default — and a v2-mode client reads v2 payloads - // natively, so every assertion below would still be green while the thing - // under test (a *v3* client reading v2) was never exercised. `v3Client` is - // typed through a thunk, so the overload collapse wouldn't surface as a type - // error either. The credential-free half of this guard — the detection matrix - // itself — is `__tests__/resolve-eql-version.test.ts`. - it('is genuinely in EQL v3 mode, or the cases below prove nothing', async () => { - const v3Payload = unwrapResult( - await v3Client.encrypt('a v3-authored note', { - table: unrelatedV3, - column: unrelatedV3.note, - }), + await client.bulkDecrypt([ + { id: '1', data: encrypted[0] }, + { id: '2', data: encrypted[1] }, + ]), ) - - expect(v3Payload).toMatchObject({ - v: 3, - i: { t: 'v2_read_compat_unrelated_v3', c: 'note' }, - }) - // The structural discriminator, and the stronger half of this guard: a v2 - // scalar carries `k: 'ct'`, a v3 scalar carries no `k` at all (see the - // narrowing note in `src/types.ts`). This still catches a regression that - // somehow preserved `v`. - expect(v3Payload).not.toHaveProperty('k') - }, 30000) - - it('decrypts a v2 ciphertext minted before the upgrade', async () => { - const encrypted = unwrapResult( - await v2Client.encrypt(SECRET, { table: usersV2, column: usersV2.email }), - ) - // Guard against a false pass: this must be a genuine v2 payload, or the - // test proves nothing about v2 compatibility. - expect(encrypted).toMatchObject({ v: 2 }) - - const decrypted = unwrapResult(await v3Client.decrypt(encrypted)) - expect(decrypted).toBe(SECRET) - }, 30000) - - it('decrypts a v2 model minted before the upgrade', async () => { - const encryptedModel = unwrapResult( - await v2Client.encryptModel({ pk: 'a', email: SECRET }, usersV2), - ) - expect(encryptedModel.email).toMatchObject({ v: 2 }) - - const decrypted = unwrapResult(await v3Client.decryptModel(encryptedModel)) - expect(decrypted).toEqual({ pk: 'a', email: SECRET }) - }, 30000) - - it('bulk-decrypts v2 ciphertexts minted before the upgrade', async () => { - const encrypted = unwrapResult( - await v2Client.bulkEncrypt( - [ - { id: '1', plaintext: SECRET }, - { id: '2', plaintext: 'grace@example.com' }, - ], - { table: usersV2, column: usersV2.email }, - ), - ) - - const decrypted = unwrapResult(await v3Client.bulkDecrypt(encrypted)) expect(decrypted).toEqual([ { id: '1', data: SECRET }, { id: '2', data: 'grace@example.com' }, ]) }, 30000) - it('decrypts a stored v2 DynamoDB item through a v3-configured adapter', async () => { - const encryptedModel = unwrapResult( - await v2Client.encryptModel({ pk: 'a', email: SECRET }, usersV2), - ) - const storedV2Item = toEncryptedDynamoItem(encryptedModel, ['email'], false) + 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 }) - // 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), + await dynamo.decryptModel(stored, users, { storedEqlVersion: 2 }), ) - expect(decrypted).toMatchObject({ pk: 'a', email: SECRET }) }, 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..6e9bae4cf 100644 --- a/packages/stack/src/dynamodb/helpers.ts +++ b/packages/stack/src/dynamodb/helpers.ts @@ -1,9 +1,7 @@ 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 type { EncryptedValue } from '@/types' -import { hasBuildColumnKeyMap } from '@/types' import { logger } from '@/utils/logger' import type { AnyEncryptedTable, EncryptedDynamoDBError } from './types' @@ -18,10 +16,6 @@ export const searchTermAttrSuffix = '__hmac' * `resolveEqlVersion` (`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 +80,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 +312,17 @@ 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[]) { 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 return undefined } } @@ -340,12 +330,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 +436,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 +449,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, @@ -483,10 +469,10 @@ export function toItemWithEqlPayloads( // it is not rebuilt per item. context: ReadContext = buildReadContext(encryptionSchema), ): 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) + const matchColumn = makeColumnMatcher(columnPaths) function processValue( attrName: string, @@ -508,10 +494,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 diff --git a/packages/stack/src/dynamodb/index.ts b/packages/stack/src/dynamodb/index.ts index bc4c5194f..a83bd6266 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' @@ -20,7 +20,7 @@ import type { * * 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 + * the typed `Encryption` 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 @@ -39,30 +39,15 @@ import type { 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 (storedEqlVersion === 2) { 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.`, + 'encryptedDynamoDB: the @cipherstash/stack/wasm-inline client cannot read DynamoDB attributes stored from EQL v2 because that entry requires registered table-aware decryption. Use the native @cipherstash/stack entry for legacy rows.', ) } return @@ -86,6 +71,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 +88,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 +118,13 @@ 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 { encryptedDynamoDB } from "@cipherstash/stack/dynamodb" - * import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema" - * - * const users = encryptedTable("users", { - * email: encryptedColumn("email").equality(), + * const decrypted = await dynamo.decryptModel(storedItem, users, { + * storedEqlVersion: 2, * }) - * - * const client = await Encryption({ schemas: [users] }) - * const dynamo = encryptedDynamoDB({ encryptionClient: client }) * ``` */ export function encryptedDynamoDB( @@ -185,20 +173,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 +219,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..3da2cf549 100644 --- a/packages/stack/src/dynamodb/operations/bulk-decrypt-models.ts +++ b/packages/stack/src/dynamodb/operations/bulk-decrypt-models.ts @@ -4,7 +4,6 @@ import { logger } from '@/utils/logger' import { buildReadContext, handleError, - isV3Table, resolveDecryptResult, throwPreservingCode, toItemWithEqlPayloads, @@ -13,6 +12,7 @@ import type { AnyEncryptedTable, CallableEncryptionClient, DynamoDBEncryptionClient, + DynamoDBReadOptions, EncryptedDynamoDBError, } from '../types' import { @@ -26,17 +26,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 +50,15 @@ 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 storedEqlVersion = this.readOptions.storedEqlVersion ?? 3 + const readContext = buildReadContext(this.table, storedEqlVersion) const itemsWithEqlPayloads = this.items.map((item) => toItemWithEqlPayloads(item, this.table, readContext), ) 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(), ) 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..6bc95c36f 100644 --- a/packages/stack/src/dynamodb/operations/decrypt-model.ts +++ b/packages/stack/src/dynamodb/operations/decrypt-model.ts @@ -2,8 +2,8 @@ import { type Result, withResult } from '@byteslice/result' import type { Decrypted, EncryptedValue } from '@/types' import { logger } from '@/utils/logger' import { + buildReadContext, handleError, - isV3Table, resolveDecryptResult, throwPreservingCode, toItemWithEqlPayloads, @@ -12,6 +12,7 @@ import type { AnyEncryptedTable, CallableEncryptionClient, DynamoDBEncryptionClient, + DynamoDBReadOptions, EncryptedDynamoDBError, } from '../types' import { @@ -25,17 +26,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 +48,18 @@ 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 + const withEqlPayloads = toItemWithEqlPayloads( + this.item, + this.table, + buildReadContext(this.table, storedEqlVersion), + ) 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(), ) 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..2b051092c 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,38 +8,25 @@ 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 @@ -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..be96a74c0 --- /dev/null +++ b/packages/stack/src/encryption/client-v3.ts @@ -0,0 +1,442 @@ +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' +import { + type BatchEncryptQueryOperation, + type BulkDecryptModelsOperation, + type BulkDecryptOperation, + type BulkEncryptModelsOperation, + type BulkEncryptOperation, + type DecryptModelOperation, + type DecryptOperation, + Encryption, + type EncryptModelOperation, + type EncryptOperation, + type EncryptQueryOperation, +} from './index' +import { + type AuditableDecryptModelOperation, + MappedDecryptOperation, +} from './operations/mapped-decrypt' + +type NativeEncryptionClient = { + 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 +}> + +/** + * 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 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. + */ + 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>>> + + bulkEncrypt<Table extends S[number], Col extends ColumnsOf<Table>>( + plaintexts: BulkEncryptPayloadFor<Col>, + opts: { table: Table; column: Col }, + ): BulkEncryptOperation + bulkDecrypt(payloads: BulkDecryptPayload): BulkDecryptOperation + getEncryptConfig(): ReturnType<NativeEncryptionClient['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 {@link EncryptionClient} in a {@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: NativeEncryptionClient, + ...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 a table given to Encryption/createEncryptionClient', + }, + } + + // 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 generic consumers 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 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, + ): 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 + } + + 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 +} + +/** + * 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: 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 and keep the typed surface: + * + * ```typescript + * let client: EncryptionClient<readonly AnyV3Table[]> + * ``` + * + * 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. + */ +// Single import surface: re-export the v3 `types` namespace + table API + type +// helpers so `@cipherstash/stack/v3` provides everything needed to author and +// use a schema. +export * from '@/eql/v3' +export { Encryption } diff --git a/packages/stack/src/encryption/index.ts b/packages/stack/src/encryption/index.ts index 079ac2b28..24bafcb99 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,21 @@ 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. +// This is a deliberate circular import with `./v3` (which imports `Encryption` +// from here): both `Encryption` and `createEncryptionClient` are hoisted `function` +// declarations, and the only module-eval cross-reference — `Encryption = +// Encryption` in `./v3` — reads the hoisted `Encryption`, so neither binding is +// observed uninitialised regardless of which module evaluates first. +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 +45,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 NativeEncryptionClient 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 +65,39 @@ 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. +/** The NativeEncryptionClient 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. */ -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 +105,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 +126,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 +139,7 @@ export class EncryptionClient { keyset: toFfiKeysetIdentifier(config.keyset), }, strategy: config.authStrategy, - eqlVersion: config.eqlVersion, + eqlVersion: 3, }) this.encryptConfig = validated @@ -751,9 +677,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 +801,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 +823,8 @@ 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 NativeEncryptionClient}, +// the collapse of the former `Encryption`. 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 +847,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 +871,14 @@ export async function Encryption( ) } + if (clientConfig && Object.hasOwn(clientConfig, 'eqlVersion')) { + 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 +898,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/v3.ts b/packages/stack/src/encryption/v3.ts index 6e25b29f0..4e200e59f 100644 --- a/packages/stack/src/encryption/v3.ts +++ b/packages/stack/src/encryption/v3.ts @@ -1,493 +1,3 @@ -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. 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..a53ece0a2 --- /dev/null +++ b/packages/stack/src/schema/internal.ts @@ -0,0 +1,754 @@ +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 +} 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..596029ae9 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 @@ -169,58 +170,8 @@ export type ClientConfig = { * but it will be removed in a future release. Set `authStrategy` instead. */ strategy?: AuthStrategy - - /** - * @deprecated The client authors EQL v3 — an all-v3 schema set forces `3` - * automatically and yields the typed client. This field remains only to read - * or write legacy EQL v2 during migration (e.g. `eqlVersion: 2` with a v2 - * schema set), and will be removed once the v2 adapters are gone. New code - * should not set it. - * - * The EQL wire version the client emits — one FFI client always emits - * exactly one wire format. - * - * - `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. - */ - eqlVersion?: 2 | 3 } -/** - * {@link ClientConfig} for a client that authors EQL v3 — the same options - * minus the legacy `eqlVersion: 2` escape hatch. - * - * `Encryption` accepts this (not the full `ClientConfig`) alongside an all-v3 - * schema set. Forcing v2 wire over v3 schemas THROWS at setup — v2 payloads - * cannot satisfy an `eql_v3_*` domain — so admitting `2` there typed the call - * as `TypedEncryptionClient` for a call that returns no client at all. - * Adapters that are v3-only (`@cipherstash/prisma-next`, - * `@cipherstash/stack-drizzle`) should take this type for their pass-through - * config for the same reason. - */ -export type V3ClientConfig = Omit<ClientConfig, 'eqlVersion'> & { - eqlVersion?: 3 -} - -type AtLeastOneCsTable<T> = [T, ...T[]] - /** Structural contract for a column builder the client can consume for STORAGE * (`encrypt`). Satisfied by v2 `EncryptedColumn` / `EncryptedField` AND v3 * `EncryptedTextSearchColumn` — fields ARE encryptable, so this stays wide. */ @@ -283,8 +234,10 @@ export function hasBuildColumnKeyMap<T extends object>( ) } -export type EncryptionClientConfig = { - schemas: AtLeastOneCsTable<BuildableTable> +export type EncryptionClientConfig< + S extends readonly AnyV3Table[] = readonly 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..fa7dd4214 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 @@ -293,6 +290,16 @@ export type WasmEncryptionConfig = { config: WasmClientConfig } +export type WasmEncryptOptions = { + table: AnyV3Table + column: AnyEncryptedV3Column +} + +type WasmQueryableV3Column = Extract< + AnyEncryptedV3Column, + { isQueryable(): true } +> + /** * Options for {@link WasmEncryptionClient.encryptQuery}. * @@ -302,9 +309,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 +347,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 +371,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 } /** @@ -750,7 +757,7 @@ export class WasmEncryptionClient { async encrypt( plaintext: WasmPlaintext, - opts: EncryptOptions, + opts: WasmEncryptOptions, ): Promise<WasmResult<Encrypted>> { return wasmResult(async () => { const ffiOpts = { @@ -1430,8 +1437,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 @@ -1468,7 +1475,7 @@ 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. + * v3 column contract accepted by {@link WasmEncryptOptions.column}. * * An `instanceof EncryptedColumn || EncryptedField` gate would type-check after * the widening but throw at runtime for a v3 column, breaking the type promise; @@ -1478,7 +1485,7 @@ export function normalizeCastAs(config: EncryptConfig): unknown { * * @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/skills/stash-dynamodb/SKILL.md b/skills/stash-dynamodb/SKILL.md index d6ba5ed19..eff4fa497 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,23 @@ 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. - -| | 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` | +Schema authoring and every write are EQL v3-only. Existing v2 DynamoDB items +remain readable with the native entry by passing the same v3 table descriptor +plus `{ storedEqlVersion: 2 }` to `decryptModel` or `bulkDecryptModels`. > **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` +> cannot perform a legacy read. 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 +67,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": { @@ -177,46 +156,28 @@ 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`.) +> `Encryption` client. -### 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, +So on Deno, Bun, Workers and Supabase Edge Functions, legacy v2 items are not readable through this adapter; re-encrypt them to a v3 schema, or read them from a Node process using the native entry. ```typescript -import { encryptedTable, encryptedColumn, encryptedField } from "@cipherstash/stack/schema" -import { Encryption } from "@cipherstash/stack" - -const 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 +441,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 +452,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 +461,22 @@ 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. | 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[]` | 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..cf9dc3a32 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,9 +182,9 @@ 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 | @@ -192,7 +192,7 @@ The SDK never logs plaintext data. | `@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/schema` | Low-level encrypt-config types and validation helpers; it is not a schema-authoring DSL | ## Schema Definition @@ -292,7 +292,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 +307,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 +979,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 +1007,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. From 3a0a0dc35e8c6a9ccd47e976b512620dd0eedc08 Mon Sep 17 00:00:00 2001 From: Toby Hede <toby@cipherstash.com> Date: Wed, 29 Jul 2026 13:45:55 +1000 Subject: [PATCH 02/11] fix(stack,skills,ci): close the gaps found reviewing #815 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six review agents checked the branch against the issue's acceptance criteria. AC2, AC3, AC6 and AC7 held; AC1, AC4 and AC5 had gaps. Each finding below was independently verified before being acted on, and each fix landed test-first. **AC4 — v2 decrypt was not preserved on the edge entry.** `encryptedDynamoDB` refused a `{ storedEqlVersion: 2 }` read on a wasm-inline client, so Deno/Bun/Workers/Supabase Edge customers could not read pre-migration items at all, with no workaround. The refusal's stated rationale — "the v2 read deliberately omits the table" — was false: `decrypt-model.ts` passes `this.table` on that path, and `v2-table-forwarding.test.ts` already pinned it. `git show a9d430b3` shows the guard was written when the branch was `if (!isV3Table(table))` and a v2 table object still existed; the v3-only rewrite removed the omission and left the guard behind. Both bindings are builds of the same protect-ffi crate, whose `decrypt` accepts either wire generation regardless of the client's `eqlVersion` — confirmed in the package README and by finding the v2 deserializer strings in both `index.node` and `protect_ffi_bg.wasm`. The refusal is lifted, and a live wasm v2 read suite now proves it in CI (no local credentials here, so that is where the executable proof runs). **AC5 — two types disagreed with their runtimes.** - `wasm-inline.ts` still typed `schemas` as a mutable non-empty tuple long after A-4 widened the native twin, so a `.map()` result or a `readonly` array compiled against one entry and failed against the other while both runtimes checked only `schemas.length`. Ported the native overload pair over. - `decryptModel(row, table, lockContext).withLockContext(lockContext)` type-checked and then threw. Split the operation interface the way the encrypt path already does: binding returns `LockBoundDecryptModelOperation`, which carries `.audit()` but not `.withLockContext()`. The runtime throw stays as the plain-JavaScript backstop. The dist declaration gate covered `encryptQuery` narrowing only, which is exactly why the wasm drift survived — that entry gets its own tsup DTS pass no source-level type test can see. It now covers schema-array shapes and config typing on both entries. Verified it has teeth: reverting the wasm fix and rebuilding makes it fail with TS2322/TS4104 against the built `.d.ts`. **AC1 — coverage gaps.** - No test covered the state a real migration leaves behind: one model carrying both generations. Every existing case was all-v2, true only until the first v3 write. - `packages/stack/src/dynamodb/**` appeared in no workflow `paths:` filter (`grep -rn dynamodb .github/` returned nothing), so edits to the only live EQL v2 read path — or a protect-ffi bump breaking v2 deserialization — never triggered the suite covering them. `src/index.ts` and `src/types.ts` were missing too. Pinned by a test that derives the requirement from what the suites actually import, rather than hardcoding today's answer. **AC6 — the removal was sound but unpinned.** The stale-reconstructor-map hazard is closed structurally: config and reconstructors derive from the same tuple in the same call, and no re-init survives. Nothing asserted that, and the two tests that had exercised it were deleted in a3830f0d — so re-adding an `init` passthrough would have failed no test. Pinned, and verified the pin fails when `init` is reintroduced. **Shipped guidance that was wrong.** `skills/stash-indexing` told agents to use `types.TOrd`, which does not exist (the table above it uses `types.NOrd` / `types.TextOrd`). Two skills stated v2 DynamoDB reads need the native entry. 23 TSDoc `@example` blocks across `encryption/index.ts` and `schema/internal.ts` told readers to import removed builders from `@cipherstash/stack/schema`; none would compile. A user-facing error named `createEncryptionClient`, which users cannot import. Comments in three files still cited `resolveEqlVersion` and `ClientConfig.eqlVersion`, both removed. Verification: `code:check` error-free; 744 stack unit tests pass (the 12 failing files are the pre-existing credential-gated suites, confirmed identical at baseline); 43 type tests; dist gate exit 0; 133 scripts tests; stack-drizzle, stack-supabase and prisma-next all green. --- .changeset/decrypt-lock-context-binds-once.md | 18 +++ .changeset/dynamodb-skill-wasm-caveat.md | 13 +- .../dynamodb-v2-read-table-forwarding.md | 6 +- .changeset/dynamodb-wasm-v2-read.md | 19 +-- .changeset/skill-indexing-ord-factory.md | 9 ++ .changeset/wasm-encryption-schema-arrays.md | 19 +++ .github/workflows/integration-drizzle.yml | 16 ++ .github/workflows/integration-supabase.yml | 16 ++ examples/prisma/src/db.ts | 2 +- .../dynamodb/v2-table-forwarding.test.ts | 36 ++++- .../stack/__tests__/typed-client-v3.test-d.ts | 46 ++++++ .../__tests__/v3-only-public-surface.test.ts | 31 ++++ .../__tests__/wasm-inline-schemas.test-d.ts | 61 ++++++++ .../stack/dist-types/node16/wasm-inline.mts | 51 ++++++- .../stack/dist-types/schemas-and-config.ts | 64 ++++++++ .../v2-decrypt-compat.integration.test.ts | 28 ++++ .../v2-decrypt-compat.integration.test.ts | 87 +++++++++++ packages/stack/src/dynamodb/helpers.ts | 2 +- packages/stack/src/dynamodb/index.ts | 26 ++-- packages/stack/src/dynamodb/types.ts | 8 +- packages/stack/src/encryption/client-v3.ts | 38 ++++- packages/stack/src/encryption/index.ts | 27 ++-- .../encryption/operations/mapped-decrypt.ts | 22 ++- packages/stack/src/schema/internal.ts | 51 ++++--- packages/stack/src/types.ts | 5 +- packages/stack/src/wasm-inline.ts | 46 +++++- .../integration-workflow-paths.test.mjs | 138 ++++++++++++++++++ skills/stash-dynamodb/SKILL.md | 22 ++- skills/stash-encryption/SKILL.md | 2 +- skills/stash-indexing/SKILL.md | 2 +- 30 files changed, 790 insertions(+), 121 deletions(-) create mode 100644 .changeset/decrypt-lock-context-binds-once.md create mode 100644 .changeset/skill-indexing-ord-factory.md create mode 100644 .changeset/wasm-encryption-schema-arrays.md create mode 100644 packages/stack/__tests__/wasm-inline-schemas.test-d.ts create mode 100644 packages/stack/dist-types/schemas-and-config.ts create mode 100644 packages/stack/integration/wasm/v2-decrypt-compat.integration.test.ts create mode 100644 scripts/__tests__/integration-workflow-paths.test.mjs diff --git a/.changeset/decrypt-lock-context-binds-once.md b/.changeset/decrypt-lock-context-binds-once.md new file mode 100644 index 000000000..6234b9258 --- /dev/null +++ b/.changeset/decrypt-lock-context-binds-once.md @@ -0,0 +1,18 @@ +--- +'@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. 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-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 ba0a2ac94..993f1f3d4 100644 --- a/.changeset/dynamodb-wasm-v2-read.md +++ b/.changeset/dynamodb-wasm-v2-read.md @@ -2,17 +2,18 @@ '@cipherstash/stack': patch --- -`encryptedDynamoDB` now refuses a `@cipherstash/stack/wasm-inline` client when a -legacy read is requested with `{ storedEqlVersion: 2 }`, 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 native v2 read path relies on generation-agnostic payload decrypt. -`WasmEncryptionClient` cannot perform that compatibility read, so the former -path surfaced a deep client error on Deno, Cloudflare Workers and Supabase Edge -Functions rather than explaining the unsupported combination. +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 directing callers -to the native entry for legacy reads. Writes remain EQL v3-only on both entries. +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/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.<N>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/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..757d5ef4b 100644 --- a/.github/workflows/integration-drizzle.yml +++ b/.github/workflows/integration-drizzle.yml @@ -26,6 +26,14 @@ 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` is the repo's only live + # EQL v2 read coverage and it exercises the DynamoDB legacy path, so a + # change here (or a protect-ffi bump) 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' - 'packages/test-kit/**' - 'packages/cli/src/installer/**' - 'local/docker-compose.postgres.yml' @@ -48,6 +56,14 @@ 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` is the repo's only live + # EQL v2 read coverage and it exercises the DynamoDB legacy path, so a + # change here (or a protect-ffi bump) 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' - '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..4f97b5c13 100644 --- a/.github/workflows/integration-supabase.yml +++ b/.github/workflows/integration-supabase.yml @@ -20,6 +20,14 @@ 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 and it exercises the DynamoDB legacy path, so a + # change here (or a protect-ffi bump) 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' - 'packages/test-kit/**' - 'packages/cli/src/installer/**' - 'local/docker-compose.supabase.yml' @@ -38,6 +46,14 @@ 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 and it exercises the DynamoDB legacy path, so a + # change here (or a protect-ffi bump) 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' - 'packages/test-kit/**' - 'packages/cli/src/installer/**' - 'local/docker-compose.supabase.yml' 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/stack/__tests__/dynamodb/v2-table-forwarding.test.ts b/packages/stack/__tests__/dynamodb/v2-table-forwarding.test.ts index 2a29c10a0..3e2de9dec 100644 --- a/packages/stack/__tests__/dynamodb/v2-table-forwarding.test.ts +++ b/packages/stack/__tests__/dynamodb/v2-table-forwarding.test.ts @@ -73,18 +73,40 @@ describe('legacy DynamoDB read forwarding', () => { expect(calls[0]?.args[1]).toBe(users) }) - it('rejects legacy reads for clients that require table-aware decrypt', () => { + it('forwards the registered v3 table for clients that require table-aware decrypt', async () => { const { calls, client } = recordingClient({ requiresTableForDecrypt: true, }) const dynamo = encryptedDynamoDB({ encryptionClient: client as never }) - expect(() => - dynamo.decryptModel({ email__source: 'ciphertext' }, users, { - storedEqlVersion: 2, - }), - ).toThrow(/wasm-inline client cannot read.*stored from EQL v2/) - expect(calls).toHaveLength(0) + await dynamo.decryptModel({ email__source: 'ciphertext' }, users, { + storedEqlVersion: 2, + }) + + 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', + }, + }) + }) + + 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.bulkDecryptModels([{ email__source: 'ciphertext' }], users, { + storedEqlVersion: 2, + }) + + expect(calls).toHaveLength(1) + expect(calls[0]?.args[1]).toBe(users) }) it('rejects an invalid stored version from JavaScript callers', () => { diff --git a/packages/stack/__tests__/typed-client-v3.test-d.ts b/packages/stack/__tests__/typed-client-v3.test-d.ts index a446ede03..5f36458ee 100644 --- a/packages/stack/__tests__/typed-client-v3.test-d.ts +++ b/packages/stack/__tests__/typed-client-v3.test-d.ts @@ -231,3 +231,49 @@ 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() + }) +}) diff --git a/packages/stack/__tests__/v3-only-public-surface.test.ts b/packages/stack/__tests__/v3-only-public-surface.test.ts index 3dad16fca..68dc8194c 100644 --- a/packages/stack/__tests__/v3-only-public-surface.test.ts +++ b/packages/stack/__tests__/v3-only-public-surface.test.ts @@ -1,9 +1,19 @@ import { describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' +import { + createEncryptionClient, + encryptedTable, + types, +} from '@/encryption/client-v3' import * as v3 from '@/encryption/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') @@ -18,4 +28,25 @@ describe('v3-only public surface', () => { 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. + */ + it('exposes no re-initialization path that could outlive the schema-derived maps', () => { + const client = createEncryptionClient({} as never, users) + + for (const method of ['init', 'reinit', 'initialize', 'configure']) { + expect(client).not.toHaveProperty(method) + } + }) }) 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..6dcd9a02d --- /dev/null +++ b/packages/stack/__tests__/wasm-inline-schemas.test-d.ts @@ -0,0 +1,61 @@ +/** + * 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 } 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 }) + }) +}) diff --git a/packages/stack/dist-types/node16/wasm-inline.mts b/packages/stack/dist-types/node16/wasm-inline.mts index 947e2b4ca..5a5c29da2 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,42 @@ 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 }) +} 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..18098cda3 --- /dev/null +++ b/packages/stack/dist-types/schemas-and-config.ts @@ -0,0 +1,64 @@ +/** + * 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' + +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 }, + }) + + // @ts-expect-error - at least one table is required + await Encryption({ schemas: [] }) +} 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 bc9788413..57a323b6c 100644 --- a/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts +++ b/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts @@ -21,6 +21,7 @@ import type { Encrypted } from '@/types' const users = encryptedTable('v2_read_compat_users', { email: types.TextEq('email'), + altEmail: types.TextEq('alt_email'), }) const SECRET = 'ada@example.com' @@ -61,6 +62,33 @@ describe('native v3 client reads stored EQL v2 payloads', () => { ).toEqual({ pk: 'a', email: SECRET }) }, 30000) + /** + * 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, + }), + ) + expect(legacy).toMatchObject({ v: 2 }) + expect(current).toMatchObject({ v: 3 }) + + 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 () => { const encrypted = (await ffiEncryptBulk(fixtureClient, { plaintexts: [ 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..7c6400e7a --- /dev/null +++ b/packages/stack/integration/wasm/v2-decrypt-compat.integration.test.ts @@ -0,0 +1,87 @@ +/** + * 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. + */ +import { + encrypt as ffiEncrypt, + newClient as newFfiClient, +} from '@cipherstash/protect-ffi' +import { unwrapResult } 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 { Encrypted } from '@/types' +import { Encryption as WasmEncryption } from '@/wasm-inline' + +const users = encryptedTable('wasm_v2_read_compat_users', { + email: types.TextEq('email'), +}) + +const SECRET = 'ada@example.com' +let fixtureClient: Awaited<ReturnType<typeof newFfiClient>> +let client: 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. + */ +beforeAll(async () => { + fixtureClient = await newFfiClient({ + encryptConfig: buildEncryptConfig(users), + eqlVersion: 2, + }) + client = await WasmEncryption({ + schemas: [users], + config: { + 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, + }, + }) +}) + +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) +}) diff --git a/packages/stack/src/dynamodb/helpers.ts b/packages/stack/src/dynamodb/helpers.ts index 6e9bae4cf..64cbd7711 100644 --- a/packages/stack/src/dynamodb/helpers.ts +++ b/packages/stack/src/dynamodb/helpers.ts @@ -13,7 +13,7 @@ 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 class EncryptedDynamoDBErrorImpl diff --git a/packages/stack/src/dynamodb/index.ts b/packages/stack/src/dynamodb/index.ts index a83bd6266..9ab249df6 100644 --- a/packages/stack/src/dynamodb/index.ts +++ b/packages/stack/src/dynamodb/index.ts @@ -31,27 +31,23 @@ import type { * 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 { - if (storedEqlVersion === 2) { - if ( - (encryptionClient as { requiresTableForDecrypt?: boolean }) - .requiresTableForDecrypt - ) { - throw new Error( - 'encryptedDynamoDB: the @cipherstash/stack/wasm-inline client cannot read DynamoDB attributes stored from EQL v2 because that entry requires registered table-aware decryption. Use the native @cipherstash/stack entry for legacy rows.', - ) - } - 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 { diff --git a/packages/stack/src/dynamodb/types.ts b/packages/stack/src/dynamodb/types.ts index 2b051092c..100bdc38f 100644 --- a/packages/stack/src/dynamodb/types.ts +++ b/packages/stack/src/dynamodb/types.ts @@ -42,10 +42,10 @@ export type DynamoDBReadOptions = { * 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 diff --git a/packages/stack/src/encryption/client-v3.ts b/packages/stack/src/encryption/client-v3.ts index be96a74c0..2786789d9 100644 --- a/packages/stack/src/encryption/client-v3.ts +++ b/packages/stack/src/encryption/client-v3.ts @@ -35,6 +35,7 @@ import { } from './index' import { type AuditableDecryptModelOperation, + type LockBoundDecryptModelOperation, MappedDecryptOperation, } from './operations/mapped-decrypt' @@ -158,11 +159,20 @@ export interface EncryptionClient< * 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, + lockContext: LockContextInput, + ): LockBoundDecryptModelOperation<V3DecryptedModel<Table, T>> + decryptModel<Table extends S[number], T extends Record<string, unknown>>( + input: T, + table: Table, ): AuditableDecryptModelOperation<V3DecryptedModel<Table, T>> /** @@ -183,7 +193,11 @@ export interface EncryptionClient< bulkDecryptModels<Table extends S[number], T extends Record<string, unknown>>( input: Array<T>, table: Table, - lockContext?: LockContextInput, + lockContext: LockContextInput, + ): 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. */ @@ -274,7 +288,7 @@ export function createEncryptionClient<const S extends readonly AnyV3Table[]>( failure: { type: EncryptionErrorTypes.DecryptionError, message: - '[eql/v3]: decryptModel received a table this client was not initialized with — pass a table given to Encryption/createEncryptionClient', + '[eql/v3]: decryptModel received a table this client was not initialized with — pass one of the tables given to Encryption({ schemas })', }, } @@ -326,7 +340,14 @@ export function createEncryptionClient<const S extends readonly AnyV3Table[]>( >( input: T, table: Table, - lockContext?: LockContextInput, + lockContext: LockContextInput, + ): 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, @@ -358,7 +379,14 @@ export function createEncryptionClient<const S extends readonly AnyV3Table[]>( >( input: Array<T>, table: Table, - lockContext?: LockContextInput, + lockContext: LockContextInput, + ): 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>, diff --git a/packages/stack/src/encryption/index.ts b/packages/stack/src/encryption/index.ts index 24bafcb99..d967a97bd 100644 --- a/packages/stack/src/encryption/index.ts +++ b/packages/stack/src/encryption/index.ts @@ -163,7 +163,7 @@ class NativeEncryptionClient { * * @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} @@ -172,9 +172,9 @@ class NativeEncryptionClient { * ```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 @@ -224,8 +224,7 @@ class NativeEncryptionClient { * @see {@link EncryptOptions} * @see {@link Result} * @see {@link encryptedTable} - * @see {@link encryptedColumn} - * @see {@link encryptedField} + * @see {@link types} * @see {@link LockContext} * @see {@link EncryptOperation} */ @@ -246,9 +245,9 @@ class NativeEncryptionClient { * ```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 @@ -398,12 +397,12 @@ class NativeEncryptionClient { * @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] }) @@ -488,12 +487,12 @@ class NativeEncryptionClient { * @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] }) @@ -569,17 +568,17 @@ class NativeEncryptionClient { * 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] }) * 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/schema/internal.ts b/packages/stack/src/schema/internal.ts index a53ece0a2..7b71d6c98 100644 --- a/packages/stack/src/schema/internal.ts +++ b/packages/stack/src/schema/internal.ts @@ -242,10 +242,11 @@ export class EncryptedField { * @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 - * import { encryptedField } from "@cipherstash/stack/schema" - * * const age = encryptedField("age").dataType("number") * ``` */ @@ -300,10 +301,11 @@ export class EncryptedColumn { * @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 - * import { encryptedColumn } from "@cipherstash/stack/schema" - * * const dateOfBirth = encryptedColumn("date_of_birth").dataType("date") * ``` */ @@ -320,10 +322,11 @@ export class EncryptedColumn { * * @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 - * import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema" - * * const users = encryptedTable("users", { * email: encryptedColumn("email").orderAndRange(), * }) @@ -344,10 +347,11 @@ export class EncryptedColumn { * 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 - * import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema" - * * const users = encryptedTable("users", { * email: encryptedColumn("email").equality(), * }) @@ -370,10 +374,11 @@ export class EncryptedColumn { * 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 - * import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema" - * * const users = encryptedTable("users", { * email: encryptedColumn("email").freeTextSearch(), * }) @@ -414,10 +419,11 @@ export class EncryptedColumn { * * @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 - * import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema" - * * const documents = encryptedTable("documents", { * metadata: encryptedColumn("metadata").searchableJson(), * }) @@ -623,10 +629,11 @@ export type InferEncrypted<T extends EncryptedTable<any>> = * @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 - * import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema" - * * const users = encryptedTable("users", { * email: encryptedColumn("email").equality().freeTextSearch(), * address: encryptedColumn("address"), @@ -671,10 +678,11 @@ export function encryptedTable<T extends EncryptedTableColumn>( * @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 - * import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema" - * * const users = encryptedTable("users", { * email: encryptedColumn("email").equality().freeTextSearch().orderAndRange(), * }) @@ -699,10 +707,11 @@ export function encryptedColumn(columnName: string) { * @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 - * import { encryptedTable, encryptedField } from "@cipherstash/stack/schema" - * * const orders = encryptedTable("orders", { * details: { * amount: encryptedField("amount").dataType("number"), @@ -721,10 +730,12 @@ export function encryptedField(valueName: string) { * @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 - * import { buildEncryptConfig } from "@cipherstash/stack/schema" - * * const users = encryptedTable("users", { * email: encryptedColumn("email").equality(), * }) diff --git a/packages/stack/src/types.ts b/packages/stack/src/types.ts index 596029ae9..919fa1d76 100644 --- a/packages/stack/src/types.ts +++ b/packages/stack/src/types.ts @@ -54,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 diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index fa7dd4214..bb2c9bb87 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -285,8 +285,14 @@ 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. + * + * Widened to `readonly AnyV3Table[]` for the same reason the native entry + * was (A-4): a mutable non-empty tuple rejects every shape that is not an + * array literal — a shared `export const all: AnyV3Table[]`, a + * `ReadonlyArray`, anything `map`-built or spread. Non-emptiness is carried + * by the {@link Encryption} overloads instead. */ + schemas: readonly AnyV3Table[] config: WasmClientConfig } @@ -690,12 +696,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 @@ -1373,6 +1382,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. * @@ -1380,6 +1400,16 @@ 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, ): Promise<WasmEncryptionClient> { diff --git a/scripts/__tests__/integration-workflow-paths.test.mjs b/scripts/__tests__/integration-workflow-paths.test.mjs new file mode 100644 index 000000000..372f78d2d --- /dev/null +++ b/scripts/__tests__/integration-workflow-paths.test.mjs @@ -0,0 +1,138 @@ +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. + */ + +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '../../..') +const STACK_SRC = 'packages/stack/src' + +/** The integration workflows and the package their `CS_IT_SUITE` globs select from. */ +const WORKFLOWS = [ + '.github/workflows/integration-drizzle.yml', + '.github/workflows/integration-supabase.yml', +] + +function readWorkflow(relPath) { + return yaml.load(readFileSync(join(REPO_ROOT, relPath), 'utf8')) +} + +/** + * `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 triggerBlocks(wf) { + const on = wf.on ?? wf[true] + return ['push', 'pull_request'] + .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 +} + +/** 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 + }) +} + +describe('integration workflow paths filters', () => { + for (const relPath of WORKFLOWS) { + 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 eff4fa497..504af7b7a 100644 --- a/skills/stash-dynamodb/SKILL.md +++ b/skills/stash-dynamodb/SKILL.md @@ -45,13 +45,10 @@ Non-encrypted attributes pass through unchanged. On decryption, the `__source` a ## Schema and Stored Versions Schema authoring and every write are EQL v3-only. Existing v2 DynamoDB items -remain readable with the native entry by passing the same v3 table descriptor -plus `{ storedEqlVersion: 2 }` to `decryptModel` or `bulkDecryptModels`. - -> **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) -> cannot perform a legacy read. Read legacy v2 items with a client from the default `@cipherstash/stack` -> entry. EQL v3 tables work on both entries. +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`. 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. @@ -163,12 +160,11 @@ const dynamo = encryptedDynamoDB({ encryptionClient }) 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 — -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 const decrypted = await dynamo.decryptModel( diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index cf9dc3a32..352453d24 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -191,7 +191,7 @@ The SDK never logs plaintext data. | `@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/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 | ## Schema Definition 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` From 6baed4976b2bc81052e07ce43d97402ce1ade810 Mon Sep 17 00:00:00 2001 From: Toby Hede <toby@cipherstash.com> Date: Wed, 29 Jul 2026 14:47:15 +1000 Subject: [PATCH 03/11] test(stack): migrate the credentialed encryptQuery suite to EQL v3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v3-only fixture migration left __tests__/encrypt-query.test.ts behind: it still asserted EQL v2 payload shape and v2 validation semantics against v3 fixtures, which is the 21-test CI failure on #829. Four distinct causes, only two of which were stale assertions. Fixture defects (these broke the tests' premises, not just their expectations): - articles.content was ported to types.TextSearch, which derives unique + ope + match. `unique` outranks `match` in inferIndexType's priority order, so auto-inference stopped selecting `match` and the numeric-value guard in validateValueIndexCompatibility — which only fires for `match` — went silent. Two tests then failed on a protect-ffi cast error instead of the guard's message, and three more passed for the wrong reason. Restored to types.TextMatch, matching the fixture's documented "only freeTextSearch" contract. - products.price is now types.NumericOrdOre, so the suite keeps live coverage of the block-ORE ordering flavour (`ore`/`ob`) alongside users.age's CLLW-OPE one (`ope`/`op`). Previously both were `_ord` and the ORE path had no live coverage at all. Stale assertions: - v: 2 -> v: 3 (13 sites); authoring is v3-only now. - `ob` -> `op` at the seven users.age sites. `_ord` domains are CLLW-OPE per orderingForEqlType; only `_ord_ore` stays block-ORE. Obsolete test: - "provides descriptive error for queryType mismatch" asserted that equality against an order-only column throws. That is v2 behaviour: a v3 order-capable column answers equality through its ordering term (equalityOrderingIndex), which is pre-existing, deliberate, and already regression-tested in schema-v3.test.ts — including the v2-preservation case. Re-pointed at articles.content, which genuinely cannot answer equality, so the test keeps its intent instead of being deleted. New coverage: - fixtures-query-contract.test.ts pins every shared fixture to the index it resolves to. Credential-free, so a fixture that drifts fails on the PR that drifts it rather than on the first CI run holding credentials. This is the test that would have caught all of the above. - The live suite now asserts equality-through-the-ordering-term on users.age (with `hm` ABSENT — the load-bearing half) and the block-ORE term on products.price via the explicit-queryType path, where the ore->ope swap must not fire. - wasm-inline-query.test.ts gains an _ord_ore column covering the `ore` branches of that swap and of equalityOrderingIndex, neither of which had credential-free coverage. Why this reached CI: the suite had no credential gate, so a local run died in beforeAll and reported "Failed Suites 1 / Tests 44 skipped" — indistinguishable from ordinary credential gating. It now skips cleanly without credentials but FAILS when CI is set, so credential rot cannot leave the suite silently covering nothing while the build stays green. No production code changed. --- .../stack/__tests__/encrypt-query.test.ts | 92 ++++++++++++++----- .../__tests__/fixtures-query-contract.test.ts | 70 ++++++++++++++ packages/stack/__tests__/fixtures/index.ts | 43 ++++++++- .../stack/__tests__/wasm-inline-query.test.ts | 33 +++++++ 4 files changed, 209 insertions(+), 29 deletions(-) create mode 100644 packages/stack/__tests__/fixtures-query-contract.test.ts diff --git a/packages/stack/__tests__/encrypt-query.test.ts b/packages/stack/__tests__/encrypt-query.test.ts index 5dfa9c4b7..8c73fb774 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,7 +680,7 @@ describe('encryptQuery', () => { expect(data).toMatchObject({ i: { t: 'users', c: 'email' }, - v: 2, + v: 3, }) expect(typeof data).toBe('object') }, 30000) @@ -685,7 +727,7 @@ describe('encryptQuery', () => { const data = unwrapResult(result) expect(data).toMatchObject({ i: { t: 'users', c: 'email' }, - v: 2, + v: 3, }) expect(data).toHaveProperty('hm') }, 30000) @@ -712,7 +754,7 @@ describe('encryptQuery', () => { const data = unwrapResult(result) expect(data).toHaveLength(2) expect(data[0]).toHaveProperty('hm') - expect(data[1]).toHaveProperty('ob') + expect(data[1]).toHaveProperty('op') }, 30000) }) }) 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 2568a22a9..dff2ebd19 100644 --- a/packages/stack/__tests__/fixtures/index.ts +++ b/packages/stack/__tests__/fixtures/index.ts @@ -13,17 +13,28 @@ export const users = encryptedTable('users', { }) /** - * 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: types.TextSearch('content'), + 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: types.IntegerOrd('price'), + price: types.NumericOrdOre('price'), }) /** @@ -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__/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( From ade9707d41d36e7d6d186cd73d92920a99a55a22 Mon Sep 17 00:00:00 2001 From: Toby Hede <toby@cipherstash.com> Date: Wed, 29 Jul 2026 15:05:07 +1000 Subject: [PATCH 04/11] fix(stack,ci,skills): close the gaps found reviewing #815 against its criteria MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewing the v3-only change against #815's acceptance criteria surfaced six gaps. All are verified against HEAD; each fix landed test-first. Config typing disagreed with runtime on both entries. `ClientConfig` and `WasmClientConfig` are all-optional, so excess-property checking was the only thing catching a leftover `config.eqlVersion` — and that fires on fresh object literals alone. A shared config const, the shape a v2 -> v3 migration actually holds, type-checked clean and threw at `Encryption()`. Both now declare `eqlVersion?: never`, pinned by hoisted-config directives in the dist gates. The runtime guards stay: JS and JSON callers bypass types entirely. The entries still disagreed about `config.eqlVersion` itself — native threw, wasm-inline accepted it silently. wasm-inline now mirrors the native guard byte-for-byte, which is the disagreement #815 exists to remove. Its non-v3-table error also stopped referring readers to the native entry for v2 authoring; that entry rejects it too, so the referral only bought a second rejection. Restore the `#1c` case the old suite explicitly asked to keep: v2 payloads read through a client configured for an unrelated table, proving decrypt never consults the encrypt config. Deleting it alongside the `eqlVersion: 2` minting hatch removed the detector for the invariant customers depend on. Fixtures still mint through protect-ffi directly, not the removed public API. Also drop two `id` fields from an `ffiEncryptBulk` call — protect-ffi's `EncryptPayload` has no such field and correlates positionally. Both integration workflows claimed a protect-ffi bump must run them while listing neither manifest that pins it. Add `packages/stack/package.json` and `pnpm-workspace.yaml` (which carries the lockstep `@cipherstash/auth` catalog pin) to all four filters; the lockfile stays out, since an exact pin cannot reach it without a manifest edit first, and both comments now say so. The guard discovers workflows by `CS_IT_SUITE` instead of hardcoding two names, and fails a one-sided edit of the duplicated push/pull_request blocks. Restore the `./types` nameability guard deleted with no replacement, and pin the v2 names out of it. Assert `root` lacks `encryptedTable` — the name the removed export line actually exported, which no test covered. Replace the re-init denylist with exact key equality against the client's ten members, so a future `withSchemas()` cannot slip past a four-name list. Correct docs the change invalidated: the README's `Encryption` sketch understated the accepted schema shapes, the encryption skill dropped two still-shipping subpaths, and `cipherstashFromStack`'s JSDoc described `config.eqlVersion` as a conditional escape hatch rather than an unconditional rejection. --- .../v3-only-config-typing-and-entry-parity.md | 35 +++++ .github/workflows/integration-drizzle.yml | 46 +++++- .github/workflows/integration-supabase.yml | 40 ++++- .../prisma-next/src/stack/from-stack-v3.ts | 9 +- packages/stack/README.md | 30 +++- .../__tests__/empty-schemas-boundary.test.ts | 4 +- .../__tests__/types-public-surface.test-d.ts | 120 +++++++++++++++ .../__tests__/v3-only-public-surface.test.ts | 40 ++++- .../stack/__tests__/wasm-inline-v3.test.ts | 70 ++++++++- .../stack/dist-types/node16/encrypt-query.cts | 39 +++++ .../stack/dist-types/node16/encrypt-query.mts | 36 +++++ .../stack/dist-types/node16/wasm-inline.mts | 27 ++++ .../stack/dist-types/schemas-and-config.ts | 12 ++ .../v2-decrypt-compat.integration.test.ts | 140 ++++++++++++++++- .../v2-decrypt-compat.integration.test.ts | 63 +++++++- packages/stack/src/types.ts | 14 ++ packages/stack/src/wasm-inline.ts | 52 +++++-- .../integration-workflow-paths.test.mjs | 144 +++++++++++++++++- skills/stash-encryption/SKILL.md | 2 + 19 files changed, 872 insertions(+), 51 deletions(-) create mode 100644 .changeset/v3-only-config-typing-and-entry-parity.md create mode 100644 packages/stack/__tests__/types-public-surface.test-d.ts 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..4ab8a4c16 --- /dev/null +++ b/.changeset/v3-only-config-typing-and-entry-parity.md @@ -0,0 +1,35 @@ +--- +'@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. diff --git a/.github/workflows/integration-drizzle.yml b/.github/workflows/integration-drizzle.yml index 757d5ef4b..7f642d26e 100644 --- a/.github/workflows/integration-drizzle.yml +++ b/.github/workflows/integration-drizzle.yml @@ -27,13 +27,30 @@ on: # 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` is the repo's only live - # EQL v2 read coverage and it exercises the DynamoDB legacy path, so a - # change here (or a protect-ffi bump) must run it. Pinned by + # 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' @@ -57,13 +74,30 @@ on: # 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` is the repo's only live - # EQL v2 read coverage and it exercises the DynamoDB legacy path, so a - # change here (or a protect-ffi bump) must run it. Pinned by + # 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 4f97b5c13..25857b159 100644 --- a/.github/workflows/integration-supabase.yml +++ b/.github/workflows/integration-supabase.yml @@ -22,12 +22,28 @@ on: - '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 and it exercises the DynamoDB legacy path, so a - # change here (or a protect-ffi bump) must run it. Pinned by + # 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' @@ -48,12 +64,28 @@ on: - '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 and it exercises the DynamoDB legacy path, so a - # change here (or a protect-ffi bump) must run it. Pinned by + # 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/packages/prisma-next/src/stack/from-stack-v3.ts b/packages/prisma-next/src/stack/from-stack-v3.ts index d2606339b..fdeeca50e 100644 --- a/packages/prisma-next/src/stack/from-stack-v3.ts +++ b/packages/prisma-next/src/stack/from-stack-v3.ts @@ -58,9 +58,12 @@ export interface CipherstashFromStackV3Options { /** * Pass-through to `Encryption({ config })` (keyset overrides, logging, …). * - * `ClientConfig`, 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 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?: ClientConfig } diff --git a/packages/stack/README.md b/packages/stack/README.md index 8fff3bd0b..bef0cdd50 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -688,13 +688,33 @@ if (result.failure) { ### `Encryption(config)` - Initialize the typed client ```typescript -function Encryption(config: { - schemas: AnyV3Table[] - config?: ClientConfig -}): Promise<EncryptionClient> +// Overload 1 — non-emptiness in the CONSTRAINT, so code that is itself +// generic over its schemas still compiles. +function Encryption<const S extends readonly [AnyV3Table, ...AnyV3Table[]]>( + config: { schemas: S; config?: ClientConfig }, +): Promise<EncryptionClient<S>> + +// 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<const S extends readonly AnyV3Table[]>( + config: { schemas: NonEmptyV3<S>; config?: ClientConfig }, +): Promise<EncryptionClient<S>> ``` -The wire format is pinned to EQL v3 — `config.eqlVersion` is not supported. +(`NonEmptyV3<S>` 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.) + +`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 diff --git a/packages/stack/__tests__/empty-schemas-boundary.test.ts b/packages/stack/__tests__/empty-schemas-boundary.test.ts index cbbbf2d4f..c9226b10a 100644 --- a/packages/stack/__tests__/empty-schemas-boundary.test.ts +++ b/packages/stack/__tests__/empty-schemas-boundary.test.ts @@ -15,7 +15,9 @@ * 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' diff --git a/packages/stack/__tests__/types-public-surface.test-d.ts b/packages/stack/__tests__/types-public-surface.test-d.ts new file mode 100644 index 000000000..68d3ac5c6 --- /dev/null +++ b/packages/stack/__tests__/types-public-surface.test-d.ts @@ -0,0 +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' +import type { + AuthStrategy, + ClientConfig, + Decrypted, + Encrypted, + 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('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('keeps the payload and model types nameable', () => { + expectTypeOf<Encrypted>().not.toBeNever() + expectTypeOf< + Decrypted<{ email: Encrypted }>['email'] + >().toEqualTypeOf<string>() + }) + + 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() + }) + + 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__/v3-only-public-surface.test.ts b/packages/stack/__tests__/v3-only-public-surface.test.ts index 68dc8194c..8e8349f31 100644 --- a/packages/stack/__tests__/v3-only-public-surface.test.ts +++ b/packages/stack/__tests__/v3-only-public-surface.test.ts @@ -20,6 +20,15 @@ describe('v3-only public surface', () => { 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') }) @@ -41,12 +50,35 @@ describe('v3-only public surface', () => { * 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', () => { - const client = createEncryptionClient({} as never, users) + // 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) - for (const method of ['init', 'reinit', 'initialize', 'configure']) { - expect(client).not.toHaveProperty(method) - } + expect(Object.keys(client).sort()).toEqual([ + 'bulkDecrypt', + 'bulkDecryptModels', + 'bulkEncrypt', + 'bulkEncryptModels', + 'decrypt', + 'decryptModel', + 'encrypt', + 'encryptModel', + 'encryptQuery', + 'getEncryptConfig', + ]) }) }) diff --git a/packages/stack/__tests__/wasm-inline-v3.test.ts b/packages/stack/__tests__/wasm-inline-v3.test.ts index 86d862908..06ae27b45 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. @@ -84,6 +90,66 @@ 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() + }) + + // 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 5a5c29da2..632f07e19 100644 --- a/packages/stack/dist-types/node16/wasm-inline.mts +++ b/packages/stack/dist-types/node16/wasm-inline.mts @@ -74,3 +74,30 @@ export async function wasmSchemaShapes() { // @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 index 18098cda3..2e6708c1e 100644 --- a/packages/stack/dist-types/schemas-and-config.ts +++ b/packages/stack/dist-types/schemas-and-config.ts @@ -59,6 +59,18 @@ export async function configShapes() { 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: [] }) } 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 57a323b6c..ef11f4863 100644 --- a/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts +++ b/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts @@ -5,6 +5,33 @@ * deliberately integration-only: production callers cannot select v2 writes, * while the native v3 client must continue to decrypt data written before the * upgrade. + * + * Two blocks, and the SECOND is the one that carries the promise: + * + * - `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. + * + * 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. + * + * 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 { encrypt as ffiEncrypt, @@ -24,11 +51,25 @@ const users = encryptedTable('v2_read_compat_users', { altEmail: types.TextEq('alt_email'), }) +/** + * 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>> 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 () => { fixtureClient = await newFfiClient({ @@ -36,6 +77,7 @@ beforeAll(async () => { eqlVersion: 2, }) client = await makeClient() + unrelatedClient = await makeUnrelatedClient() }) async function v2Ciphertext(value: string): Promise<Encrypted> { @@ -90,11 +132,13 @@ describe('native v3 client reads stored EQL v2 payloads', () => { }, 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: [ - { id: '1', plaintext: SECRET, table: users.tableName, column: 'email' }, + { plaintext: SECRET, table: users.tableName, column: 'email' }, { - id: '2', plaintext: 'grace@example.com', table: users.tableName, column: 'email', @@ -127,3 +171,95 @@ describe('native v3 client reads stored EQL v2 payloads', () => { expect(decrypted).toMatchObject({ pk: 'a', email: SECRET }) }, 30000) }) + +/** + * 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) + }) + + 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' }, + }) + + expect(unwrapResult(await unrelatedClient.decrypt(encrypted))).toBe(SECRET) + }, 30000) + + it('decrypts a model whose encrypted field belongs to an unregistered table', async () => { + const encrypted = await v2Ciphertext(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 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 unrelatedClient.bulkDecrypt([ + { id: '1', data: first }, + { id: '2', data: second }, + ]), + ) + expect(decrypted).toEqual([ + { id: '1', data: SECRET }, + { id: '2', data: 'grace@example.com' }, + ]) + }, 30000) + + /** + * The adapter reaches the same conclusion by a different route, so it needs + * its own case. `assertClientTableVersionMatch` normally REFUSES a table the + * client has not registered — that guard catches a v3 write aimed at the + * wrong client — but it early-returns for `storedEqlVersion: 2` + * (`src/dynamodb/index.ts`), precisely because a v2 payload says nothing + * about which v3 tables the client holds. The table argument survives only to + * drive envelope reconstruction. If that early return is ever tightened into + * a registration check, this case is what fails. + */ + it('decrypts a stored v2 DynamoDB item through an adapter on the unrelated client', async () => { + const encrypted = await v2Ciphertext(SECRET) + const stored = toEncryptedDynamoItem({ pk: 'a', email: encrypted }, [ + 'email', + ]) + const dynamo = encryptedDynamoDB({ encryptionClient: unrelatedClient }) + + const decrypted = unwrapResult( + await dynamo.decryptModel(stored, users, { storedEqlVersion: 2 }), + ) + expect(decrypted).toMatchObject({ pk: 'a', email: SECRET }) + }, 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 index 7c6400e7a..66207c861 100644 --- a/packages/stack/integration/wasm/v2-decrypt-compat.integration.test.ts +++ b/packages/stack/integration/wasm/v2-decrypt-compat.integration.test.ts @@ -14,6 +14,24 @@ * * 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. */ import { encrypt as ffiEncrypt, @@ -31,15 +49,32 @@ 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), @@ -47,12 +82,11 @@ beforeAll(async () => { }) client = await WasmEncryption({ schemas: [users], - config: { - 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, - }, + config: wasmCredentials(), + }) + unrelatedClient = await WasmEncryption({ + schemas: [unrelated], + config: wasmCredentials(), }) }) @@ -84,4 +118,21 @@ describe('wasm-inline v3 client reads stored EQL v2 payloads', () => { ) 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) }) diff --git a/packages/stack/src/types.ts b/packages/stack/src/types.ts index 919fa1d76..c6485aa12 100644 --- a/packages/stack/src/types.ts +++ b/packages/stack/src/types.ts @@ -169,6 +169,20 @@ export type ClientConfig = { * but it will be removed in a future release. Set `authStrategy` instead. */ strategy?: AuthStrategy + + /** + * 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()`. + * + * `Encryption` keeps its runtime guard: JS and JSON callers bypass types + * entirely, so this is defence in depth, not a replacement for it. + */ + eqlVersion?: never } /** Structural contract for a column builder the client can consume for STORAGE diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index bb2c9bb87..858070f86 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -223,6 +223,20 @@ 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. + */ + eqlVersion?: never // Provide exactly one of `accessKey` (we build the strategy) or a // pre-built auth strategy — never both, never neither. } & ( @@ -1421,15 +1435,31 @@ 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. + if (clientConfig && Object.hasOwn(clientConfig, 'eqlVersion')) { + 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.', ) } } @@ -1502,16 +1532,18 @@ 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 - * v3 column contract accepted by {@link WasmEncryptOptions.column}. + * 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. */ diff --git a/scripts/__tests__/integration-workflow-paths.test.mjs b/scripts/__tests__/integration-workflow-paths.test.mjs index 372f78d2d..d47c67bba 100644 --- a/scripts/__tests__/integration-workflow-paths.test.mjs +++ b/scripts/__tests__/integration-workflow-paths.test.mjs @@ -20,30 +20,65 @@ import { describe, expect, it } from 'vitest' * 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' -/** The integration workflows and the package their `CS_IT_SUITE` globs select from. */ -const WORKFLOWS = [ - '.github/workflows/integration-drizzle.yml', - '.github/workflows/integration-supabase.yml', -] +/** 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 ['push', 'pull_request'] - .map((event) => on?.[event]) - .filter((block) => block && Array.isArray(block.paths)) + 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. */ @@ -104,6 +139,54 @@ function importedSourcePaths(file) { 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) => { @@ -112,8 +195,53 @@ function isCovered(target, paths) { }) } +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) diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 352453d24..39b1392a3 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -193,6 +193,8 @@ The SDK never logs plaintext data. | `@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 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 From 7f8d04918487aad7e989488eeea13235d1217ead Mon Sep 17 00:00:00 2001 From: Toby Hede <toby@cipherstash.com> Date: Wed, 29 Jul 2026 15:09:24 +1000 Subject: [PATCH 05/11] test(stack): drop the two vacuous live lock-context encryptQuery tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "executes a single/bulk query bound to an identity claim" were the only tests in the repo that executed a lock-context ZeroKMS call under CI's service credentials. On the EQL v3 wire they fail with `ZeroKMS error 'Request forbidden due to insufficient permissions'`. They are invalid by construction, and have never asserted anything about lock contexts. Why they cannot be valid: a lock context binds a data key to an end user's identity claim, so it requires an OidcFederationStrategy-authenticated client. `skills/stash-encryption/SKILL.md` (added in 8bb698e2) states that AccessKeyStrategy "authenticates a *service* and cannot be used with a lock context", and the same rule is in LockContext's TSDoc. This suite builds its client from the CS_* service credentials with no authStrategy, so the claim has no JWT to resolve from. The 403 is the correct response. Why they were never meaningful: - Introduced in fea303d0 with a mock that forwarded a literal `accessToken: 'mock-token'` to the live service as `serviceToken` — and passed. Had ZeroKMS been authorising the binding, that string would have been rejected. - Their assertions (`i`, `v`, `hm`, `op`) are identical to the non-lock-context tests earlier in the same file, so neither could distinguish a bound claim from an ignored one. - Per 8d707cc9 ("search terms are not identity-bound — decryption is the boundary"), query terms are not identity-bound at all: the `hm` term is workspace-scoped and matches with or without a lock context. There is no behaviour at this layer for them to assert. Deleting rather than gating or moving: - Gating on USER_JWT is not available: that secret was never provisioned (#530) and f143dfec retired it for CLERK_MACHINE_TOKEN, wired only into the integration workflows. - Moving them to integration/identity/ would add nothing — per 8d707cc9 a query-term assertion there is shape-only, and the decrypt-boundary coverage that matters already exists in integration/identity/matrix-identity.integration.test.ts and packages/stack-drizzle/integration/lock-context.integration.test.ts, both green on this branch via "Integration — Drizzle (EQL v3)". - No coverage is lost here: the two sibling tests still pin that `.withLockContext()` returns an executable operation, and lock-context-wiring.test.ts pins claim forwarding against a mocked FFI. Also stops what protect-ffi's README describes as invalid lock contexts firing security warnings in ZeroKMS on every CI run. Left open, tracked separately: nothing in this repo explains why the v3 wire reaches an authorization check the v2 wire did not. Answering that needs protect-ffi/ZeroKMS, and it bears on what v2 lock contexts on query terms actually guaranteed. --- .../stack/__tests__/encrypt-query.test.ts | 66 +++++++------------ 1 file changed, 23 insertions(+), 43 deletions(-) diff --git a/packages/stack/__tests__/encrypt-query.test.ts b/packages/stack/__tests__/encrypt-query.test.ts index 8c73fb774..89008b7b3 100644 --- a/packages/stack/__tests__/encrypt-query.test.ts +++ b/packages/stack/__tests__/encrypt-query.test.ts @@ -686,6 +686,29 @@ describe.skipIf(skipWithoutLiveCredentials)('encryptQuery', () => { }, 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', { @@ -713,48 +736,5 @@ describe.skipIf(skipWithoutLiveCredentials)('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: 3, - }) - 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('op') - }, 30000) }) }) From 9c1f7a7041b1b9a4df106969cd055346a30bb63b Mon Sep 17 00:00:00 2001 From: Toby Hede <toby@cipherstash.com> Date: Wed, 29 Jul 2026 15:19:47 +1000 Subject: [PATCH 06/11] fix(stack,skills): pin the DynamoDB legacy-read boundary instead of asserting past it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The restored `#1c` block added a DynamoDB case reading through a client that never registered the table, and it fails: the adapter 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. Two independent registration checks sit on that path, and clearing the first does not clear the second. `assertClientTableVersionMatch` early-returns for `storedEqlVersion: 2` because a v2 payload says nothing about which v3 tables the client holds; the guard in `client-v3.ts` still fires. The restored case mistook the first for the only one. The pre-#815 case passed a v2 table descriptor and so never reached the v3 guard. That shape is unreproducible now the v2 builders are gone, and requiring the table to be declared is a coherent contract — the caller needs a descriptor to pass anyway. So the case is inverted to pin the boundary rather than deleted, and the file header now scopes the config-independence invariant to the table-less reads that actually hold it. The native and WASM cases through the registered client are untouched and still passing. `skills/stash-dynamodb` documented the descriptor a legacy read takes but not that it must be one of the tables given to `Encryption({ schemas })` — stated now, where the signature is. --- .../v3-only-config-typing-and-entry-parity.md | 7 +++ .../v2-decrypt-compat.integration.test.ts | 44 +++++++++++++------ skills/stash-dynamodb/SKILL.md | 7 +++ 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/.changeset/v3-only-config-typing-and-entry-parity.md b/.changeset/v3-only-config-typing-and-entry-parity.md index 4ab8a4c16..6f5822aa6 100644 --- a/.changeset/v3-only-config-typing-and-entry-parity.md +++ b/.changeset/v3-only-config-typing-and-entry-parity.md @@ -33,3 +33,10 @@ overloads, including the `readonly` and non-literal array forms. The bundled 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/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts b/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts index ef11f4863..c912e47a4 100644 --- a/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts +++ b/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts @@ -28,6 +28,12 @@ * 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 @@ -241,25 +247,37 @@ describe('a client that never registered the v2 table still reads its payloads', }, 30000) /** - * The adapter reaches the same conclusion by a different route, so it needs - * its own case. `assertClientTableVersionMatch` normally REFUSES a table the - * client has not registered — that guard catches a v3 write aimed at the - * wrong client — but it early-returns for `storedEqlVersion: 2` - * (`src/dynamodb/index.ts`), precisely because a v2 payload says nothing - * about which v3 tables the client holds. The table argument survives only to - * drive envelope reconstruction. If that early return is ever tightened into - * a registration check, this case is what fails. + * 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('decrypts a stored v2 DynamoDB item through an adapter on the unrelated client', async () => { + 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 decrypted = unwrapResult( - await dynamo.decryptModel(stored, users, { storedEqlVersion: 2 }), - ) - expect(decrypted).toMatchObject({ pk: 'a', email: SECRET }) + const result = await dynamo.decryptModel(stored, users, { + storedEqlVersion: 2, + }) + + expect(result.failure?.message).toMatch(/was not initialized with/) }, 30000) }) diff --git a/skills/stash-dynamodb/SKILL.md b/skills/stash-dynamodb/SKILL.md index 504af7b7a..6706b2dfe 100644 --- a/skills/stash-dynamodb/SKILL.md +++ b/skills/stash-dynamodb/SKILL.md @@ -465,6 +465,13 @@ Let `T` be inferred from the argument; do not pass explicit type arguments on th 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, v3Table, { storedEqlVersion: 2 })` | `T` | From 90c38735f2e0dc2d00d69dfc4908fe5682f49536 Mon Sep 17 00:00:00 2001 From: Toby Hede <toby@cipherstash.com> Date: Wed, 29 Jul 2026 15:39:41 +1000 Subject: [PATCH 07/11] fix(stack,stack-drizzle,stack-supabase): close the defects found reviewing #829 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review of the PR — three agents over the diff, then two verifying the findings — turned up defects in both commits, including three in the review-fix commit itself. Each was reproduced before being acted on. **A type-breaking change was shipped as a patch.** Splitting `decryptModel` / `bulkDecryptModels` into arity-2 and arity-3 overloads dropped `undefined` from the third parameter, so `decryptModel(row, users, session?.lockContext)` — the ordinary shape for decrypting identity-bound rows only for signed-in users — stopped compiling. Proven against the built `.d.ts`, not just source. The positional overloads now take `LockContextInput | undefined`; `undefined` binds nothing, and binding twice is still a compile error. **Two tests were left type-broken by that same split**, with comments asserting the opposite of the new types, and CI could not see it: `vitest.config.ts` scopes typecheck to `*.test-d.ts`. They now model a plain-JavaScript caller explicitly — the runtime throw is the backstop for callers who never see these types, and chaining directly no longer compiles. **The exported wasm config type laundered an empty schema set.** Widening `WasmEncryptionConfig.schemas` to `readonly AnyV3Table[]` also made it unable to prove non-emptiness: `const cfg: WasmEncryptionConfig = { schemas: [], config }` compiled, and so did `Encryption(cfg)`, because `NonEmptyV3<readonly AnyV3Table[]>` resolves `S['length']` to `number` rather than `0`. Only the direct literal was rejected. Non-emptiness moves back onto the exported type, with the loose shape kept as an unexported implementation type; the factory still accepts widened arrays inline, which is what A-4 was about. **Nested EQL v2 DynamoDB reads returned raw base64 as a success.** A v2 grouped column registered its build key on the bare leaf, so a field in a group was written `<group>.<leaf>__source` while the schema knew it only as `<leaf>`. The v3 rewrite made matching exact-dotted-path only, for both generations, orphaning every such attribute behind a debug log that is invisible at the default level. The fallback is restored for `storedEqlVersion: 2` reads only — v3 registers full dotted paths precisely so a nested leaf cannot collide with a top-level column, and matching by bare leaf there once rewrote a plaintext sibling as an envelope and handed it to the FFI. **Tests that could not fail.** The two wasm-shaped DynamoDB cases set a capability flag nothing reads, making them byte-equivalent to the pair above them; their stub now throws when called table-less, verified by breaking the forwarding and watching three tests fail. A Drizzle type test asserted the same thing twice (`EncryptionClient<readonly AnyV3Table[]>` is definitionally the defaulted `EncryptionClient`) and now also covers a concrete tuple. A Supabase `@ts-expect-error` had degenerated to proving "an arbitrary object is rejected"; its stub is v2-table-shaped again, and the rejection now cites the v3 table's discriminating members. **Docs that overclaimed.** A shipped JSDoc and its changeset both said an adapter can name `EncryptionClient<readonly AnyV3Table[]>` and "keep the typed surface". This PR's own Supabase adapter carries a cast disproving it: with the schema parameter loose there is no per-column plaintext to resolve, so the model input stays untyped. Both now say so. `AGENTS.md` credited v2 decrypt to the native client alone, which stopped being true when the wasm entry gained it. Two findings were deliberately not acted on. The `assertClientTableVersionMatch` early return was settled by 8de757f8 with a second guard and a pinning test. Failing closed on an unmatched `*__source` would regress the passthrough that `helpers-v3.test.ts` pins on purpose — treating one as an envelope previously handed unrelated customer plaintext to the FFI. One instruction to a delegated agent was itself wrong: that a nominal `EncryptionClient<S>` parameter would reject a narrower schema tuple. Its members are declared method-style, so the instantiations relate bivariantly and both directions are accepted. The agent tested it, refused to write the false comment, and wrote the verified reason instead — that naming a ten-member interface nominally rejects anything supplying only the consumed capability. Verification: 760 stack unit tests, 51 type tests, dist declaration gate exit 0, 138 scripts tests, Supabase 489, Drizzle 371, Prisma Next 344, Biome error-free. The 11 failing stack files are 10 credential-gated suites and the pre-existing Clerk dependency-resolution issue this PR's description already notes. --- .changeset/decrypt-lock-context-binds-once.md | 7 +++ .changeset/dynamodb-v2-grouped-field-reads.md | 25 +++++++++ .changeset/encryption-schema-arrays.md | 8 ++- AGENTS.md | 2 +- .../__tests__/operators.test-d.ts | 52 +++++++++++++------ packages/stack-drizzle/src/operators.ts | 26 ++++++---- .../__tests__/column-map-predicate.test.ts | 11 ++-- .../__tests__/supabase-v3.test-d.ts | 9 ++++ .../decrypt-audit-forwarding.test.ts | 27 +++++++--- .../__tests__/dynamodb/helpers-v3.test.ts | 50 ++++++++++++++++++ .../dynamodb/v2-table-forwarding.test.ts | 29 +++++++++++ .../stack/__tests__/typed-client-v3.test-d.ts | 32 ++++++++++++ .../__tests__/wasm-inline-schemas.test-d.ts | 36 ++++++++++++- packages/stack/src/dynamodb/helpers.ts | 23 ++++++-- packages/stack/src/encryption/client-v3.ts | 18 +++++-- packages/stack/src/wasm-inline.ts | 29 ++++++++--- skills/stash-dynamodb/SKILL.md | 10 ++++ 17 files changed, 342 insertions(+), 52 deletions(-) create mode 100644 .changeset/dynamodb-v2-grouped-field-reads.md diff --git a/.changeset/decrypt-lock-context-binds-once.md b/.changeset/decrypt-lock-context-binds-once.md index 6234b9258..51a169112 100644 --- a/.changeset/decrypt-lock-context-binds-once.md +++ b/.changeset/decrypt-lock-context-binds-once.md @@ -16,3 +16,10 @@ 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/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 `<group>.<leaf>__source` while the schema knew it only as +`<leaf>`. 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/encryption-schema-arrays.md b/.changeset/encryption-schema-arrays.md index 00cc71720..c3cbb5f39 100644 --- a/.changeset/encryption-schema-arrays.md +++ b/.changeset/encryption-schema-arrays.md @@ -30,7 +30,13 @@ rejected. `EncryptionClient<S>` 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<readonly AnyV3Table[]>` and keep the typed surface. +`EncryptionClient<readonly AnyV3Table[]>`. + +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<string, unknown>` 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. diff --git a/AGENTS.md b/AGENTS.md index 49c9dec06..236ee15aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,7 +145,7 @@ Three rules to remember when editing CI or pnpm config: ## Key Concepts and APIs - **Initialization**: `Encryption({ schemas })` is the single client factory. It requires at least one concrete EQL v3 `encryptedTable` and returns `EncryptionClient<S>`, 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. The native client still decrypts existing v2 payloads. DynamoDB legacy reads use the same v3 table descriptor plus `{ storedEqlVersion: 2 }`; nested v3 fields use a flat dotted column path (`'profile.ssn': types.TextEq(...)`). +- **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/packages/stack-drizzle/__tests__/operators.test-d.ts b/packages/stack-drizzle/__tests__/operators.test-d.ts index 37e1e347c..43da25c3b 100644 --- a/packages/stack-drizzle/__tests__/operators.test-d.ts +++ b/packages/stack-drizzle/__tests__/operators.test-d.ts @@ -1,26 +1,44 @@ import type { Result } from '@byteslice/result' import type { AuditConfig } from '@cipherstash/stack/adapter-kit' +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, EncryptionClient } 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 - * `EncryptionClient` that `Encryption` resolves to — the documented - * `createEncryptionOperators(await Encryption({ 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<readonly AnyV3Table[]>` are the SAME type — asserting + * both would be one assertion written twice, which is why only one appears. + * 2. `EncryptionClient<readonly [typeof users]>` — 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 = EncryptionClient<readonly AnyV3Table[]> + const users = encryptedTable('users', { + email: types.TextSearch('email'), + age: types.IntegerOrd('age'), + }) // A query operation resolving `Result<T, …>` — the surface the factory drives. type QueryOp<T> = { @@ -29,13 +47,15 @@ describe('createEncryptionOperators - client parameter (M1)', () => { then: PromiseLike<Result<T, EncryptionError>>['then'] } - it('accepts the client Encryption returns with no cast', () => { - expectTypeOf(createEncryptionOperators).toBeCallableWith({} as V3Client) + it('accepts the defaulted EncryptionClient', () => { + expectTypeOf(createEncryptionOperators).toBeCallableWith( + {} as EncryptionClient, + ) }) - it('accepts the generic EncryptionClient', () => { + it('accepts a client built for a concrete schema tuple', () => { expectTypeOf(createEncryptionOperators).toBeCallableWith( - {} as EncryptionClient, + {} as EncryptionClient<readonly [typeof users]>, ) }) diff --git a/packages/stack-drizzle/src/operators.ts b/packages/stack-drizzle/src/operators.ts index 6f5666c25..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 `EncryptionClient` that `Encryption` returns - * (whatever its schema tuple), AND by a hand-rolled test double, none needing a - * cast. Typing the parameter to the nominal `EncryptionClient<S>` 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,9 +188,10 @@ type ChainableOperation<T> = { * {@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 `EncryptionClient` from `Encryption` (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. * diff --git a/packages/stack-supabase/__tests__/column-map-predicate.test.ts b/packages/stack-supabase/__tests__/column-map-predicate.test.ts index 6940fc06d..6add989cb 100644 --- a/packages/stack-supabase/__tests__/column-map-predicate.test.ts +++ b/packages/stack-supabase/__tests__/column-map-predicate.test.ts @@ -114,10 +114,13 @@ 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. + // 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: () => ({}) } diff --git a/packages/stack-supabase/__tests__/supabase-v3.test-d.ts b/packages/stack-supabase/__tests__/supabase-v3.test-d.ts index c69d10cb2..c79ff2044 100644 --- a/packages/stack-supabase/__tests__/supabase-v3.test-d.ts +++ b/packages/stack-supabase/__tests__/supabase-v3.test-d.ts @@ -272,8 +272,17 @@ describe('encryptedSupabaseV3 typed surface (with schemas)', () => { }) it('rejects a v2 table in schemas', async () => { + // 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 diff --git a/packages/stack/__tests__/decrypt-audit-forwarding.test.ts b/packages/stack/__tests__/decrypt-audit-forwarding.test.ts index 1d6d861d4..dc1df92cc 100644 --- a/packages/stack/__tests__/decrypt-audit-forwarding.test.ts +++ b/packages/stack/__tests__/decrypt-audit-forwarding.test.ts @@ -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/helpers-v3.test.ts b/packages/stack/__tests__/dynamodb/helpers-v3.test.ts index 0f3ec35fe..144f06139 100644 --- a/packages/stack/__tests__/dynamodb/helpers-v3.test.ts +++ b/packages/stack/__tests__/dynamodb/helpers-v3.test.ts @@ -501,3 +501,53 @@ 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 `<group>.<leaf>__source` while the schema knew + * it only as `<leaf>`. 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 <leaf>__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) + }) +}) diff --git a/packages/stack/__tests__/dynamodb/v2-table-forwarding.test.ts b/packages/stack/__tests__/dynamodb/v2-table-forwarding.test.ts index 3e2de9dec..2a1886637 100644 --- a/packages/stack/__tests__/dynamodb/v2-table-forwarding.test.ts +++ b/packages/stack/__tests__/dynamodb/v2-table-forwarding.test.ts @@ -6,11 +6,35 @@ const users = encryptedTable('users', { email: types.TextEq('email'), }) +/** + * @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(options: { requiresTableForDecrypt?: boolean } = {}) { const calls: { method: string; args: unknown[] }[] = [] const record = (method: string) => (...args: unknown[]) => { + 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') ? [{}] : {} }) } @@ -73,6 +97,11 @@ describe('legacy DynamoDB read forwarding', () => { expect(calls[0]?.args[1]).toBe(users) }) + // 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, diff --git a/packages/stack/__tests__/typed-client-v3.test-d.ts b/packages/stack/__tests__/typed-client-v3.test-d.ts index 5f36458ee..17ca69f92 100644 --- a/packages/stack/__tests__/typed-client-v3.test-d.ts +++ b/packages/stack/__tests__/typed-client-v3.test-d.ts @@ -277,3 +277,35 @@ describe('typed v3 client — a lock context binds exactly once', () => { ).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__/wasm-inline-schemas.test-d.ts b/packages/stack/__tests__/wasm-inline-schemas.test-d.ts index 6dcd9a02d..6643b8ba6 100644 --- a/packages/stack/__tests__/wasm-inline-schemas.test-d.ts +++ b/packages/stack/__tests__/wasm-inline-schemas.test-d.ts @@ -10,7 +10,11 @@ */ import { describe, expectTypeOf, it } from 'vitest' import { type AnyV3Table, encryptedTable, types } from '@/eql/v3' -import { Encryption, type WasmEncryptionClient } from '@/wasm-inline' +import { + Encryption, + type WasmEncryptionClient, + type WasmEncryptionConfig, +} from '@/wasm-inline' const users = encryptedTable('users', { email: types.TextEq('email'), @@ -59,3 +63,33 @@ describe('wasm-inline Encryption schema typing', () => { 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/src/dynamodb/helpers.ts b/packages/stack/src/dynamodb/helpers.ts index 64cbd7711..8d1c21a9b 100644 --- a/packages/stack/src/dynamodb/helpers.ts +++ b/packages/stack/src/dynamodb/helpers.ts @@ -316,13 +316,28 @@ function isStoredEqlPayload(value: unknown): value is StoredEqlPayload { * 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(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 (paths.has(dotted)) return dotted + if (allowBareLeaf && prefix && paths.has(leaf)) return leaf return undefined } } @@ -472,7 +487,9 @@ export function toItemWithEqlPayloads( const { v, encryptConfig, columnPaths, toColumnName } = context // The same matcher the write path splits with, so the two stay symmetric. - const matchColumn = makeColumnMatcher(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, diff --git a/packages/stack/src/encryption/client-v3.ts b/packages/stack/src/encryption/client-v3.ts index 2786789d9..837316d33 100644 --- a/packages/stack/src/encryption/client-v3.ts +++ b/packages/stack/src/encryption/client-v3.ts @@ -168,7 +168,7 @@ export interface EncryptionClient< decryptModel<Table extends S[number], T extends Record<string, unknown>>( input: T, table: Table, - lockContext: LockContextInput, + lockContext: LockContextInput | undefined, ): LockBoundDecryptModelOperation<V3DecryptedModel<Table, T>> decryptModel<Table extends S[number], T extends Record<string, unknown>>( input: T, @@ -193,7 +193,7 @@ export interface EncryptionClient< bulkDecryptModels<Table extends S[number], T extends Record<string, unknown>>( input: Array<T>, table: Table, - lockContext: LockContextInput, + lockContext: LockContextInput | undefined, ): LockBoundDecryptModelOperation<Array<V3DecryptedModel<Table, T>>> bulkDecryptModels<Table extends S[number], T extends Record<string, unknown>>( input: Array<T>, @@ -340,7 +340,7 @@ export function createEncryptionClient<const S extends readonly AnyV3Table[]>( >( input: T, table: Table, - lockContext: LockContextInput, + lockContext: LockContextInput | undefined, ): LockBoundDecryptModelOperation<V3DecryptedModel<Table, T>> function decryptModel< Table extends S[number], @@ -379,7 +379,7 @@ export function createEncryptionClient<const S extends readonly AnyV3Table[]>( >( input: Array<T>, table: Table, - lockContext: LockContextInput, + lockContext: LockContextInput | undefined, ): LockBoundDecryptModelOperation<Array<V3DecryptedModel<Table, T>>> function bulkDecryptModels< Table extends S[number], @@ -453,12 +453,20 @@ export function createEncryptionClient<const S extends readonly AnyV3Table[]>( * ``` * * 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: + * 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. diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index 858070f86..7e0950d81 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -301,11 +301,28 @@ export type WasmEncryptionConfig = { /** One or more EQL v3 tables, authored with `types` / `encryptedTable` from * this entry. The WASM entry is EQL v3 only. * - * Widened to `readonly AnyV3Table[]` for the same reason the native entry - * was (A-4): a mutable non-empty tuple rejects every shape that is not an - * array literal — a shared `export const all: AnyV3Table[]`, a - * `ReadonlyArray`, anything `map`-built or spread. Non-emptiness is carried - * by the {@link Encryption} overloads instead. */ + * `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 } @@ -1425,7 +1442,7 @@ export function Encryption<const S extends readonly AnyV3Table[]>(config: { config: WasmClientConfig }): Promise<WasmEncryptionClient> export async function Encryption( - config: WasmEncryptionConfig, + config: WasmEncryptionConfigInput, ): Promise<WasmEncryptionClient> { const { schemas, config: clientConfig } = config diff --git a/skills/stash-dynamodb/SKILL.md b/skills/stash-dynamodb/SKILL.md index 6706b2dfe..b6c10855b 100644 --- a/skills/stash-dynamodb/SKILL.md +++ b/skills/stash-dynamodb/SKILL.md @@ -477,6 +477,16 @@ as you still need to read its v2 rows. | `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')`). + 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`, `DynamoDBReadOptions`, `DynamoDBEncryptionClient`, `EncryptedAttributes`, `DecryptedAttributes`, `AuditConfig`. From 0f37883d813e5aa375ae650207b6e843d8225d32 Mon Sep 17 00:00:00 2001 From: Toby Hede <toby@cipherstash.com> Date: Wed, 29 Jul 2026 17:14:08 +1000 Subject: [PATCH 08/11] refactor(stack-supabase): split term collection out of query-encrypt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `Analyze v3 complexity` job failed: `query-encrypt.ts` scored 71.51 against the FTA cap of 71. 3aff6cb6 added 35 lines to the file, and a3d6abb8 had just ratcheted the cap 91 -> 71, so the file crossed a line the repo had only recently tightened. Raising the cap would undo that ratchet, so the file is split instead. `encryptFilterValues` was doing two unrelated jobs: a pure synchronous walk over `DbQuerySpace` deciding WHICH operands need encrypting, then the async FFI work of encrypting them. The walk moves to `./query-terms` — `collectQueryTerms`, `TermMapping`, `CollectedQueryTerm`, `queryTypeForRawOp`, `queryTypeForOrOp`, `assertEncryptedSearchOperand` — and `encryptFilterValues` becomes the three lines that join the halves. The name follows the existing `query-dbspace` / `query-filters` / `query-results` convention. Capability validation deliberately does NOT move: `assertTermQueryable` stays the single boundary for it, so every collected spelling is still checked identically rather than once per loop. Also updated: the module map in `query-builder.ts`'s header, and two `{@link}`s in `assertTermQueryable`'s doc that pointed at the moved functions. No changeset — `dist/index.d.ts` contains none of the moved symbols, so nothing here was ever public and behaviour is unchanged. Verification: all three complexity gates exit 0 (query-encrypt.ts 71.51 -> 60.60, new query-terms.ts 59.07 — both clear of the cap rather than back on it); 536 Supabase tests pass; build clean including DTS; Biome error-free. The 11 `tsc --noEmit` errors in `__tests__/supabase-v3-matrix.test.ts` are pre-existing — identical count on the unmodified tree — and invisible to CI because `vitest.config.ts` scopes typecheck to `*.test-d.ts`. --- packages/stack-supabase/src/query-builder.ts | 8 +- packages/stack-supabase/src/query-encrypt.ts | 304 +----------------- packages/stack-supabase/src/query-terms.ts | 312 +++++++++++++++++++ 3 files changed, 323 insertions(+), 301 deletions(-) create mode 100644 packages/stack-supabase/src/query-terms.ts 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<string>() * 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<string, unknown>, diff --git a/packages/stack-supabase/src/query-encrypt.ts b/packages/stack-supabase/src/query-encrypt.ts index a12fee6f2..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 @@ -59,14 +36,6 @@ export type EncryptedFilterState = { termMap: TermMapping[] } -type CollectedQueryTerm = { - value: ScalarQueryTerm['value'] - column: V3ColumnLike - table: AnyV3Table - queryType?: QueryTypeName - returnType?: ScalarQueryTerm['returnType'] -} - /** * Everything an encryption step needs from the builder. Assembled per * `execute()`, so `lockContext`/`auditConfig` are read at execution time — not @@ -188,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, @@ -278,202 +183,7 @@ export async function encryptFilterValues( dbSpace: DbQuerySpace, ctx: EncryptionContext, ): Promise<EncryptedFilterState> { - // Collect all terms that need encryption - 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, - }) - } - + const { terms, termMap } = collectQueryTerms(dbSpace, ctx) if (terms.length === 0) { return { encryptedValues: [], termMap: [] } } @@ -547,9 +257,9 @@ 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: CollectedQueryTerm, 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<EncryptionContext, 'table' | 'columns'>, +): { 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 } +} From e1fb68f6fd3f4c87bd4e881d556ec08e20206513 Mon Sep 17 00:00:00 2001 From: Toby Hede <toby@cipherstash.com> Date: Wed, 29 Jul 2026 18:44:11 +1000 Subject: [PATCH 09/11] docs(stack,skills): correct the comments the v3-only rename left wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewing the branch turned up seven sites where the `EncryptionV3` -> `Encryption` and `TypedEncryptionClient` -> `NativeEncryptionClient` rename was applied to prose that no longer parses, one orphaned doc block, one broken shipped example, and a dead re-export that made two modules cyclic at runtime. Each was confirmed against the code before being changed. No behaviour changes. **The `./index` <-> `./client-v3` cycle is removed.** `client-v3.ts` imported `Encryption` as a VALUE solely to feed a trailing `export { Encryption }` that nothing imports — `encryption/v3.ts` re-exports it straight from `./index`. Since `./index` imports `createEncryptionClient` as a value, that made the pair genuinely cyclic at module eval. The whole `./index` import is now type-only, so values flow one way. Verified acyclic rather than assumed: `client-v3`'s remaining value imports are `@/eql/v3/{columns,date-reconstruction}`, `@/errors` and `./operations/mapped-decrypt`, none of which reach back into `@/encryption`. The six-line comment in `index.ts` arguing the cycle was safe via function hoisting goes with it. It was stale independently: `git log -S` traces it to 8832d358, when this file was `./v3` and contained `export const EncryptionV3 = Encryption` — the module-eval assignment the argument was actually about, and which no longer exists. **The orphaned doc block is reattached.** ~30 lines documenting the deleted `EncryptionClientFor` alias sat above the file's re-exports, documenting nothing and invisible in the generated reference. Merged into the exported `EncryptionClient` interface — the loose-`S` model-typing caveat and the `Awaited<ReturnType<typeof Encryption>>` warning are the guidance callers most need and were reachable by nobody. **Two comments say something FALSE, not merely stale**, so they are rewritten rather than renamed: - `dynamodb/index.ts` claimed "both client shapes DO expose `getEncryptConfig()`". `WasmEncryptionClient` has none — zero occurrences in `wasm-inline.ts` — which is precisely the unreadable-config case the guard early-returns on. Naming it makes the silence deliberate rather than luck. - `dynamodb/types.ts` described "both NATIVE clients", their `DecryptModelOperation` and `MappedDecryptOperation` as alternatives. There is one native client, and its decrypt-model path returns a `MappedDecryptOperation` WRAPPING a `DecryptModelOperation`. **`skills/stash-dynamodb` shipped a destroyed sentence** — "whether the client came from `Encryption` or the `Encryption` client". Line 491 of the same file states the fact correctly; the changeset dynamodb-skill-wasm-caveat.md shows a prior PR fixed that one and missed this. Now consistent, and it agrees with the source comment in `dynamodb/types.ts` that wasm-inline drops audit metadata. This ships into customer repos. **The `encryptedDynamoDB` v2 example was not runnable**: an edit removed the table and client construction but kept the imports, leaving `dynamo`, `users` and `storedItem` undeclared and both imports unused. Rewritten complete, and cross-checked against the skill. Also: the `NativeEncryptionClient` name was a non-exported class in `index.ts` AND an unrelated structural type in `client-v3.ts`. The type — 3 references, no public surface — is now `UnderlyingNativeClient`, named for its role. The "single import surface" comment moved to `encryption/v3.ts`, the entry it describes. The one-arg `decryptModel` rationale was reworded, not dropped: it still explains the legacy EQL v2 read path, and regains the provenance ("the arity the pre-v3 client exposed") that "mirroring the nominal client" carried. Two tests imported `encryptedTable`/`types` from the internal `@/encryption/client-v3`, which is what kept its `export * from '@/eql/v3'` alive; they now take them from `@/eql/v3`. `v3-only-public-surface.test.ts` asserts nothing about `client-v3`'s own surface, so this does not weaken it. No changeset: v3-only-encryption-client.md and v3-only-config-typing-and-entry-parity.md already carry `stash: patch` and both cover the bundled skills — the latter has a paragraph on stash-dynamodb — so the bump and changelog entry exist. Nothing else here is public behaviour. Verification: Biome, `@cipherstash/stack` build, test:types and test:types:dist all exit 0. `packages/stack` vitest is 764 passed / 157 skipped / 11 failed — the same 11 by cause as the unmodified tree (10 missing local credentials, 1 unresolvable `@clerk/shared/telemetry`), so no new failure. `pnpm run build` fails at `@cipherstash/bench` both with and without these changes: `packages/bench/node_modules` symlinks into another worktree, so bench resolves `@cipherstash/stack` to a stale dist still exporting `EncryptionClientFor`, which appears nowhere in this checkout. --- .../stack/__tests__/typed-client-v3.test.ts | 7 +- .../__tests__/v3-only-public-surface.test.ts | 7 +- packages/stack/src/dynamodb/index.ts | 30 +++- packages/stack/src/dynamodb/types.ts | 10 +- packages/stack/src/encryption/client-v3.ts | 157 ++++++++++-------- packages/stack/src/encryption/index.ts | 29 ++-- packages/stack/src/encryption/v3.ts | 3 + skills/stash-dynamodb/SKILL.md | 5 +- 8 files changed, 138 insertions(+), 110 deletions(-) diff --git a/packages/stack/__tests__/typed-client-v3.test.ts b/packages/stack/__tests__/typed-client-v3.test.ts index e3eab54ac..563e49c96 100644 --- a/packages/stack/__tests__/typed-client-v3.test.ts +++ b/packages/stack/__tests__/typed-client-v3.test.ts @@ -1,10 +1,7 @@ import { describe, expect, it } from 'vitest' import type { EncryptionClient } from '@/encryption' -import { - createEncryptionClient, - encryptedTable, - types, -} from '@/encryption/client-v3' +import { createEncryptionClient } from '@/encryption/client-v3' +import { encryptedTable, types } from '@/eql/v3' const table = encryptedTable('t', { when: types.Timestamp('when'), diff --git a/packages/stack/__tests__/v3-only-public-surface.test.ts b/packages/stack/__tests__/v3-only-public-surface.test.ts index 8e8349f31..21e5cf37d 100644 --- a/packages/stack/__tests__/v3-only-public-surface.test.ts +++ b/packages/stack/__tests__/v3-only-public-surface.test.ts @@ -1,11 +1,8 @@ import { describe, expect, it } from 'vitest' import type { EncryptionClient } from '@/encryption' -import { - createEncryptionClient, - encryptedTable, - types, -} from '@/encryption/client-v3' +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' diff --git a/packages/stack/src/dynamodb/index.ts b/packages/stack/src/dynamodb/index.ts index 9ab249df6..35efc47cf 100644 --- a/packages/stack/src/dynamodb/index.ts +++ b/packages/stack/src/dynamodb/index.ts @@ -18,14 +18,15 @@ 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 `Encryption` 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` @@ -117,7 +118,20 @@ function resolveStoredEqlVersion(options: DynamoDBReadOptions): 2 | 3 { * @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" + * + * // 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: 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, * }) diff --git a/packages/stack/src/dynamodb/types.ts b/packages/stack/src/dynamodb/types.ts index 100bdc38f..7c2af2b90 100644 --- a/packages/stack/src/dynamodb/types.ts +++ b/packages/stack/src/dynamodb/types.ts @@ -29,11 +29,11 @@ export type DynamoDBReadOptions = { * 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 diff --git a/packages/stack/src/encryption/client-v3.ts b/packages/stack/src/encryption/client-v3.ts index 837316d33..68dd046ea 100644 --- a/packages/stack/src/encryption/client-v3.ts +++ b/packages/stack/src/encryption/client-v3.ts @@ -20,18 +20,24 @@ import type { EncryptedReturnType, EncryptOptions, } from '@/types' -import { - type BatchEncryptQueryOperation, - type BulkDecryptModelsOperation, - type BulkDecryptOperation, - type BulkEncryptModelsOperation, - type BulkEncryptOperation, - type DecryptModelOperation, - type DecryptOperation, +// 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, - type EncryptModelOperation, - type EncryptOperation, - type EncryptQueryOperation, + EncryptModelOperation, + EncryptOperation, + EncryptQueryOperation, } from './index' import { type AuditableDecryptModelOperation, @@ -39,7 +45,18 @@ import { MappedDecryptOperation, } from './operations/mapped-decrypt' -type NativeEncryptionClient = { +/** + * 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, @@ -88,7 +105,8 @@ type BulkEncryptPayloadFor<Col> = Array<{ }> /** - * A strongly-typed view over an {@link EncryptionClient} for EQL v3 schemas. + * 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: @@ -102,6 +120,35 @@ type BulkEncryptPayloadFor<Col> = Array<{ * - `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. */ @@ -176,15 +223,17 @@ export interface EncryptionClient< ): 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. + * 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`. 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. + * `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, @@ -210,7 +259,7 @@ export interface EncryptionClient< opts: { table: Table; column: Col }, ): BulkEncryptOperation bulkDecrypt(payloads: BulkDecryptPayload): BulkDecryptOperation - getEncryptConfig(): ReturnType<NativeEncryptionClient['getEncryptConfig']> + getEncryptConfig(): ReturnType<UnderlyingNativeClient['getEncryptConfig']> } /** @@ -250,16 +299,16 @@ function rowReconstructor( } /** - * Wrap an already-built {@link EncryptionClient} in a {@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. + * 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: NativeEncryptionClient, + client: UnderlyingNativeClient, ...schemas: S ): EncryptionClient<S> { // Precompute one row reconstructor per schema table at construction. This runs @@ -292,19 +341,21 @@ export function createEncryptionClient<const S extends readonly AnyV3Table[]>( }, } - // Pass-through maps for a one-arg (nominal-style) decrypt call, where `table` - // is absent: decrypt WITHOUT date reconstruction, exactly as the nominal - // `EncryptionClient` does. This client is now what `Encryption` returns for a - // v3 schema set, so generic consumers can call `decryptModel(x)` - // / `bulkDecryptModels(xs)` with no table. Degrade gracefully instead of + // 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 nominal - // client (which routes to the batch operation when no `opts` are supplied). + // 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. @@ -357,7 +408,7 @@ export function createEncryptionClient<const S extends readonly AnyV3Table[]>( table?: AnyV3Table, lockContext?: LockContextInput, ): AuditableDecryptModelOperation<never> { - // `table` is absent on a nominal-style one-arg call (see `passthroughRow`). + // `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. @@ -398,7 +449,7 @@ export function createEncryptionClient<const S extends readonly AnyV3Table[]>( ): AuditableDecryptModelOperation<never> { const op = client.bulkDecryptModels(input) const base = lockContext ? op.withLockContext(lockContext) : op - // No table → pass rows through (nominal behaviour). Registered table → + // No table → pass rows through (no date reconstruction). Registered table → // reconstruct each row. Unregistered table → `undefined` map → // `unknownTableFailure` on execute. let mapRows: @@ -438,41 +489,3 @@ export function createEncryptionClient<const S extends readonly AnyV3Table[]>( 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: 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. - */ -// Single import surface: re-export the v3 `types` namespace + table API + type -// helpers so `@cipherstash/stack/v3` provides everything needed to author and -// use a schema. -export * from '@/eql/v3' -export { Encryption } diff --git a/packages/stack/src/encryption/index.ts b/packages/stack/src/encryption/index.ts index d967a97bd..7d7256cb4 100644 --- a/packages/stack/src/encryption/index.ts +++ b/packages/stack/src/encryption/index.ts @@ -26,12 +26,9 @@ import type { } from '@/types' import { hasBuildColumnKeyMap } from '@/types' import { logger } from '@/utils/logger' -// `createEncryptionClient` wraps the internal FFI client with the public schema-derived API. -// This is a deliberate circular import with `./v3` (which imports `Encryption` -// from here): both `Encryption` and `createEncryptionClient` are hoisted `function` -// declarations, and the only module-eval cross-reference — `Encryption = -// Encryption` in `./v3` — reads the hoisted `Encryption`, so neither binding is -// observed uninitialised regardless of which module evaluates first. +// `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' @@ -48,7 +45,7 @@ import { EncryptQueryOperation } from './operations/encrypt-query' export type { EncryptionClient } from './client-v3' -// Re-export the operation classes returned by NativeEncryptionClient methods so they +// 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 { @@ -82,10 +79,15 @@ function assertV3Schemas(schemas: readonly AnyV3Table[]): void { } } -/** The NativeEncryptionClient 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. */ class NativeEncryptionClient { private client: Client @@ -822,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 NativeEncryptionClient}, -// the collapse of the former `Encryption`. 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 diff --git a/packages/stack/src/encryption/v3.ts b/packages/stack/src/encryption/v3.ts index 4e200e59f..6084578cc 100644 --- a/packages/stack/src/encryption/v3.ts +++ b/packages/stack/src/encryption/v3.ts @@ -1,3 +1,6 @@ +// 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' export type { EncryptionClient } from './client-v3' export { Encryption } from './index' diff --git a/skills/stash-dynamodb/SKILL.md b/skills/stash-dynamodb/SKILL.md index b6c10855b..a7a68980f 100644 --- a/skills/stash-dynamodb/SKILL.md +++ b/skills/stash-dynamodb/SKILL.md @@ -152,8 +152,9 @@ 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 -> `Encryption` client. +> 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. ### Reading Existing EQL v2 Items From d5391279917d7b0f569f719bba4a3cc99e37f0d5 Mon Sep 17 00:00:00 2001 From: Toby Hede <toby@cipherstash.com> Date: Wed, 29 Jul 2026 19:10:13 +1000 Subject: [PATCH 10/11] test(stack,test-kit): cover EQL v2 reads across the type catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2 read coverage was broad across surfaces — scalar, model, bulk, mixed v2/v3, unregistered-table — but text-only across types: every v2 fixture in the repo used `types.TextEq`. Writes are proven for all 40 domains by the v3 matrix; reads had exactly one. Add an entry-agnostic v2 fixture plan (`packages/test-kit/src/v2-fixtures.ts`) driven from `V3_MATRIX`, and consume it from all three surfaces that read legacy payloads: - native (`integration/shared`): 101 cases, 29 domains, 6 plaintext axes - wasm-inline (`integration/wasm`): the same matrix plus a scalar bigint pin — wasm reconstructs per value via `reconstructDateValue`, a separate implementation from native's path-aware `reconstructDatePaths`, so it needs its own proof rather than inheriting native's - DynamoDB (`__tests__/dynamodb`): 116 cases including nested dotted-path columns; unit-testable with a stub at the protect-ffi boundary, so this surface runs without credentials Every case asserts `{ v: 2, i: { t, c } }` on the fixture before asserting any value: without that the matrices would pass just as happily against v3 payloads and prove nothing about legacy reads. The identifier is checked too, since `encryptBulk` correlates positionally and a mis-zipped batch would otherwise go green by coincidence. 29 of 40 domains are minted. The 11 deferrals carry written reasons: the 10 ope-indexed domains (a v2 payload from an ope column is data that never existed — the removed v2 builder's `orderAndRange()` emitted `ore`) and `json_search` (cipherstash-client 0.42 refuses to emit a v2 ste_vec, which also makes `json` the one unreachable cast_as). No plaintext axis is lost: each deferred domain shares its cast_as with a covered sibling. The accounting guards live in `packages/stack/__tests__/test-kit-v2-fixtures.test.ts` rather than only inside the integration suite that consumes the plan. They are pure functions over `V3_MATRIX`, and a guard whose purpose is to fire when someone adds a domain is worth little if it only fires for the subset of contributors holding integration credentials. Characterises one genuine defect found on the way: a v2 GROUPED date column decrypts but is never reconstructed. `makeColumnMatcher`'s bare-leaf fallback rebuilds the envelope at the nested position while `rowReconstructor` keys date paths on the declared path, so `reconstructDatePaths` finds nothing and the value returns as the FFI's string — no throw, no data loss, simply not a `Date`. Pinned as characterisation, not endorsement, with the caller-side workaround (declare the dotted path) documented. Not fixed here. Test-only; no changeset (`@cipherstash/test-kit` is private). --- .../dynamodb/v2-table-forwarding.test.ts | 99 ++- .../dynamodb/v2-type-coverage.test.ts | 749 ++++++++++++++++++ .../__tests__/test-kit-v2-fixtures.test.ts | 73 ++ .../v2-decrypt-compat.integration.test.ts | 281 ++++++- .../v2-decrypt-compat.integration.test.ts | 295 ++++++- packages/test-kit/src/index.ts | 13 + packages/test-kit/src/v2-fixtures.ts | 324 ++++++++ 7 files changed, 1830 insertions(+), 4 deletions(-) create mode 100644 packages/stack/__tests__/dynamodb/v2-type-coverage.test.ts create mode 100644 packages/stack/__tests__/test-kit-v2-fixtures.test.ts create mode 100644 packages/test-kit/src/v2-fixtures.ts diff --git a/packages/stack/__tests__/dynamodb/v2-table-forwarding.test.ts b/packages/stack/__tests__/dynamodb/v2-table-forwarding.test.ts index 2a1886637..67c7fd967 100644 --- a/packages/stack/__tests__/dynamodb/v2-table-forwarding.test.ts +++ b/packages/stack/__tests__/dynamodb/v2-table-forwarding.test.ts @@ -1,6 +1,22 @@ +/** + * Which TABLE a legacy DynamoDB read forwards, and to which client shape. + * + * 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 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 { describe, expect, it } from 'vitest' import { encryptedDynamoDB } from '@/dynamodb' -import { encryptedTable, types } from '@/eql/v3' +import { type AnyV3Table, encryptedTable, types } from '@/eql/v3' +import { DATE_LIKE_CASTS } from '@/eql/v3/columns' +import { reconstructDatePaths } from '@/eql/v3/date-reconstruction' const users = encryptedTable('users', { email: types.TextEq('email'), @@ -147,5 +163,86 @@ describe('legacy DynamoDB read forwarding', () => { storedEqlVersion: 4, } as never), ).toThrow(/unsupported storedEqlVersion 4/) + expect(() => + dynamo.bulkDecryptModels([{ email__source: 'ciphertext' }], users, { + storedEqlVersion: 4, + } as never), + ).toThrow(/unsupported storedEqlVersion 4/) + }) +}) + +/** + * 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 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), + ), + ) + .map(([property]) => property) + } + + it('returns a Date for a date column stored as EQL v2', async () => { + const client = { + decryptModel: (item: Record<string, unknown>, 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.decryptModel( + { pk: 'p#1', bornOn__source: '1990-04-05T00:00:00.000Z' }, + people, + { storedEqlVersion: 2 }, + ) + + expect(result.failure).toBeUndefined() + if (result.failure) return + + const data = result.data as Record<string, unknown> + 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..35b5a42b3 --- /dev/null +++ b/packages/stack/__tests__/dynamodb/v2-type-coverage.test.ts @@ -0,0 +1,749 @@ +/** + * 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 + * `<attr>__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<typeof createEncryptionClient>[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<R>(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<string, unknown> + tables: readonly AnyV3Table[] +}) { + /** Models handed DOWN to the FFI boundary, in call order. */ + const received: Array<Record<string, unknown>> = [] + + 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<string, unknown> + 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<string, unknown>) => { + received.push(input) + return fakeOp({ data: decryptTree(input) }) + }, + bulkDecryptModels: (input: Array<Record<string, unknown>>) => { + 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 `<attr>__source` value a legacy item holds for this fixture. */ +const ciphertext = (fixture: Pick<V2FixtureCase, 'slug' | 'row'>) => + `ct:${fixture.slug}#${fixture.row}` + +/** Which domains stored a `<attr>__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<string, unknown> = { + 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<Record<string, unknown>> + let decrypted: Array<Record<string, unknown>> + let singleRow: Record<string, unknown> + + 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<string, unknown> }).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<string, unknown> }).profile + expect(profile.birthday).toBeInstanceOf(Date) + expect(profile.seenAt).toBeInstanceOf(Date) + } + }) +}) + +/** + * CHARACTERISATION, not endorsement — and the one genuine gap this file found. + * + * A v2 GROUPED column registered its build key on the bare leaf, so a field + * inside a group was stored as `<group>.<leaf>__source` while the schema knew it + * only as `<leaf>`. `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 date + * reconstruction is keyed on the DECLARED path (`birthday`), so + * `reconstructDatePaths` looks for a top-level `birthday`, does not find one, + * and the value comes back as the FFI's string. The data is not lost and nothing + * throws — it is simply not a `Date`. + * + * The fix available to a caller today is in the test above: declare the dotted + * path (`'details.birthday': types.Date(...)`), which makes the exact-path match + * fire and reconstruction follow it. + */ +describe('a v2 grouped date column decrypts but is not reconstructed', () => { + const orders = encryptedTable('orders', { + birthday: types.Date('birthday'), + }) + + it('rebuilds the envelope under the group but leaves the date a string', 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<string, unknown> }).details + expect(details.birthday).toBe('1990-04-05T00:00:00.000Z') + expect(details.birthday).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<Table, T>` — 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__/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/integration/shared/v2-decrypt-compat.integration.test.ts b/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts index c912e47a4..505e94814 100644 --- a/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts +++ b/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts @@ -6,7 +6,9 @@ * while the native v3 client must continue to decrypt data written before the * upgrade. * - * Two blocks, and the SECOND is the one that carries the promise: + * 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: * * - `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 @@ -18,6 +20,9 @@ * 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. * * 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 @@ -39,17 +44,27 @@ * `config: { eqlVersion: 2 }` escape hatch still existed; the hatch is gone * (`Encryption()` now rejects the field outright), the invariant is not. */ +import type { JsPlaintext } from '@cipherstash/protect-ffi' import { encrypt as ffiEncrypt, encryptBulk as ffiEncryptBulk, newClient as newFfiClient, } from '@cipherstash/protect-ffi' -import { unwrapResult } from '@cipherstash/test-kit' +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 { buildEncryptConfig, encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' +import type { CastAs } from '@/schema' import type { Encrypted } from '@/types' const users = encryptedTable('v2_read_compat_users', { @@ -281,3 +296,265 @@ describe('a client that never registered the v2 table still reads its payloads', 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), + ) + singleRow = unwrapResult( + await matrixClient.decryptModel(encrypted[0], matrix), + ) + }, 60000) + + 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(), + ) + }) + + /** + * 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 index 66207c861..757f0cd51 100644 --- a/packages/stack/integration/wasm/v2-decrypt-compat.integration.test.ts +++ b/packages/stack/integration/wasm/v2-decrypt-compat.integration.test.ts @@ -32,16 +32,28 @@ * 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 } from '@cipherstash/test-kit' +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' @@ -136,3 +148,284 @@ describe('wasm-inline v3 client reads stored EQL v2 payloads', () => { 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/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] +} From c745db71253f8306de76e7c7563f974d44fdfd77 Mon Sep 17 00:00:00 2001 From: Toby Hede <toby@cipherstash.com> Date: Wed, 29 Jul 2026 20:11:02 +1000 Subject: [PATCH 11/11] fix(stack,prisma-next,skills): close the three P2s from the v3-only review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grouped v2 date columns lost their `Date`. A legacy grouped field is stored as `<group>.<leaf>__source` while the v2 schema knew it only as `<leaf>`, so a `{ storedEqlVersion: 2 }` read matches it by bare leaf and the recursion re-nests the rebuilt envelope, landing the plaintext at `details.placedAt`. Both clients resolve date columns from the DECLARED paths — `rowReconstructor` on the native entry, `dateFields` on wasm-inline — so neither found it and the value came back as an ISO string, contradicting the guarantee stated in `dynamodb/index.ts` and the DynamoDB skill. Fixed in the adapter rather than either client: the read path is the only layer that knows the alias fired, and reconstructing on the returned plaintext covers both entries at once. `toItemWithEqlPayloads` reports the paths it actually wrote to, and the decrypt operations reconstruct there — one set PER ITEM on the bulk path, since `details.placedAt` can be an encrypted date column in one item and an ordinary plaintext string in the next. The characterisation block in `v2-type-coverage.test.ts` that pinned this as known behaviour now asserts the fix. `EncryptionClientConfig` stopped rejecting an empty schema set. Its default type argument widened to `readonly AnyV3Table[]`, where `S['length']` resolves to `number` and the non-empty conditional no longer fires, so a config built once and passed around — the shape the type exists to serve — typechecked clean and threw at `Encryption()`. Restores the non-empty tuple default, matching the fix `90c38735` already applied to `WasmEncryptionConfig`, and gates it against the BUILT declarations as well as source. `config.eqlVersion: undefined` typechecked but threw. `eqlVersion?: never` has declared type `undefined` without `exactOptionalPropertyTypes` (not enabled in this repo) and no declaration can reject it, so a bare `Object.hasOwn` presence check failed a config the published types accept. Both factories now tolerate that one value — it names no version — and still reject every real one, `eqlVersion: 3` included. Corrects the prisma-next comment that restated the old "whatever its value" rule. --- .changeset/v3-only-review-followups.md | 30 ++++ .../prisma-next/src/stack/from-stack-v3.ts | 8 +- .../__tests__/dynamodb/helpers-v3.test.ts | 93 ++++++++++ .../dynamodb/v2-grouped-date.test.ts | 160 ++++++++++++++++++ .../dynamodb/v2-type-coverage.test.ts | 73 ++++++-- .../__tests__/encryption-v3-only.test-d.ts | 41 ++++- .../stack/__tests__/init-strategy.test.ts | 27 +++ .../stack/__tests__/wasm-inline-v3.test.ts | 15 ++ .../stack/dist-types/schemas-and-config.ts | 19 +++ packages/stack/src/dynamodb/helpers.ts | 29 ++++ .../operations/bulk-decrypt-models.ts | 25 ++- .../src/dynamodb/operations/decrypt-model.ts | 16 +- packages/stack/src/encryption/index.ts | 12 +- packages/stack/src/types.ts | 27 ++- packages/stack/src/wasm-inline.ts | 16 +- skills/stash-dynamodb/SKILL.md | 4 + 16 files changed, 570 insertions(+), 25 deletions(-) create mode 100644 .changeset/v3-only-review-followups.md create mode 100644 packages/stack/__tests__/dynamodb/v2-grouped-date.test.ts 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 `<group>.<leaf>__source` while the v2 schema +knew it only as `<leaf>`, 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/packages/prisma-next/src/stack/from-stack-v3.ts b/packages/prisma-next/src/stack/from-stack-v3.ts index fdeeca50e..c144d5c22 100644 --- a/packages/prisma-next/src/stack/from-stack-v3.ts +++ b/packages/prisma-next/src/stack/from-stack-v3.ts @@ -61,9 +61,11 @@ export interface CipherstashFromStackV3Options { * `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 and whatever - * the schema set, so a config object carrying a leftover `eqlVersion` fails at - * setup rather than silently selecting a wire format. + * (`` `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?: ClientConfig } diff --git a/packages/stack/__tests__/dynamodb/helpers-v3.test.ts b/packages/stack/__tests__/dynamodb/helpers-v3.test.ts index 144f06139..edc0f6b00 100644 --- a/packages/stack/__tests__/dynamodb/helpers-v3.test.ts +++ b/packages/stack/__tests__/dynamodb/helpers-v3.test.ts @@ -551,3 +551,96 @@ describe('stored EQL v2 grouped fields', () => { ).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<string>() + + 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<string>() + + 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<string>() + + 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<string>() + + 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<string>() + + toItemWithEqlPayloads( + groupedItem('placedAt'), + dotted, + buildReadContext(dotted, 2), + aliasedDatePaths, + ) + + expect([...aliasedDatePaths]).toEqual([]) + }) +}) 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 `<group>.<leaf>__source` while the v2 + * schema knew it only as `<leaf>`, 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<string, unknown>[]) { + 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-type-coverage.test.ts b/packages/stack/__tests__/dynamodb/v2-type-coverage.test.ts index 35b5a42b3..0d4f0bd6b 100644 --- a/packages/stack/__tests__/dynamodb/v2-type-coverage.test.ts +++ b/packages/stack/__tests__/dynamodb/v2-type-coverage.test.ts @@ -489,7 +489,7 @@ describe('nested columns on a legacy read', () => { }) /** - * CHARACTERISATION, not endorsement — and the one genuine gap this file found. + * 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 `<group>.<leaf>__source` while the schema knew it @@ -497,22 +497,20 @@ describe('nested columns on a legacy read', () => { * 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 date - * reconstruction is keyed on the DECLARED path (`birthday`), so - * `reconstructDatePaths` looks for a top-level `birthday`, does not find one, - * and the value comes back as the FFI's string. The data is not lost and nothing - * throws — it is simply not a `Date`. - * - * The fix available to a caller today is in the test above: declare the dotted - * path (`'details.birthday': types.Date(...)`), which makes the exact-path match - * fire and reconstruction follow it. + * 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 decrypts but is not reconstructed', () => { +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 but leaves the date a string', async () => { + 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], @@ -538,8 +536,55 @@ describe('a v2 grouped date column decrypts but is not reconstructed', () => { }, }) const details = (decrypted as { details: Record<string, unknown> }).details - expect(details.birthday).toBe('1990-04-05T00:00:00.000Z') - expect(details.birthday).not.toBeInstanceOf(Date) + 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<string, unknown> }).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<string, unknown> }).details + expect(details.label).toBe('1990-04-05T00:00:00.000Z') + expect(details.label).not.toBeInstanceOf(Date) }) }) diff --git a/packages/stack/__tests__/encryption-v3-only.test-d.ts b/packages/stack/__tests__/encryption-v3-only.test-d.ts index 2e06ca15e..c7d610e73 100644 --- a/packages/stack/__tests__/encryption-v3-only.test-d.ts +++ b/packages/stack/__tests__/encryption-v3-only.test-d.ts @@ -1,7 +1,7 @@ import { describe, expectTypeOf, it } from 'vitest' import { Encryption, type EncryptionClient } from '@/encryption' import { type AnyV3Table, encryptedTable, types } from '@/eql/v3' -import type { ClientConfig } from '@/types' +import type { ClientConfig, EncryptionClientConfig } from '@/types' const users = encryptedTable('users', { email: types.TextEq('email'), @@ -49,3 +49,42 @@ describe('v3-only Encryption contract', () => { >() }) }) + +/** + * 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<readonly [AnyV3Table, ...AnyV3Table[]]> + >() + }) + + it('still parameterizes over an explicit schema tuple', () => { + expectTypeOf< + EncryptionClientConfig<readonly [typeof users]>['schemas'] + >().toEqualTypeOf<readonly [typeof users]>() + }) + + it('still accepts a widened array passed inline to the factory', async () => { + const widened: AnyV3Table[] = [users] + expectTypeOf(await Encryption({ schemas: widened })).toEqualTypeOf< + EncryptionClient<AnyV3Table[]> + >() + }) +}) diff --git a/packages/stack/__tests__/init-strategy.test.ts b/packages/stack/__tests__/init-strategy.test.ts index 450e85319..d8a3459aa 100644 --- a/packages/stack/__tests__/init-strategy.test.ts +++ b/packages/stack/__tests__/init-strategy.test.ts @@ -212,4 +212,31 @@ describe('Encryption v3 wire format', () => { expect(ffi.newClient).not.toHaveBeenCalled() }) + + it('rejects an explicit eqlVersion 3, which is a stale config and not a request', async () => { + await expect( + Encryption({ + schemas: [users], + config: { eqlVersion: 3 } as never, + }), + ).rejects.toThrow(/config\.eqlVersion.*removed/) + + expect(ffi.newClient).not.toHaveBeenCalled() + }) + + /** + * `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( + Encryption({ schemas: [users], config: { eqlVersion: undefined } }), + ).resolves.toBeDefined() + + expect(lastNewClientOpts().eqlVersion).toBe(3) + }) }) diff --git a/packages/stack/__tests__/wasm-inline-v3.test.ts b/packages/stack/__tests__/wasm-inline-v3.test.ts index 06ae27b45..917f1f180 100644 --- a/packages/stack/__tests__/wasm-inline-v3.test.ts +++ b/packages/stack/__tests__/wasm-inline-v3.test.ts @@ -137,6 +137,21 @@ describe('wasm-inline is EQL v3 only (#614)', () => { 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. diff --git a/packages/stack/dist-types/schemas-and-config.ts b/packages/stack/dist-types/schemas-and-config.ts index 2e6708c1e..6498bdd4c 100644 --- a/packages/stack/dist-types/schemas-and-config.ts +++ b/packages/stack/dist-types/schemas-and-config.ts @@ -15,6 +15,7 @@ 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'), @@ -74,3 +75,21 @@ export async function configShapes() { // @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/src/dynamodb/helpers.ts b/packages/stack/src/dynamodb/helpers.ts index 8d1c21a9b..a7d06085a 100644 --- a/packages/stack/src/dynamodb/helpers.ts +++ b/packages/stack/src/dynamodb/helpers.ts @@ -1,6 +1,7 @@ import type { ProtectErrorCode } from '@cipherstash/protect-ffi' import { ProtectError as FfiProtectError } from '@cipherstash/protect-ffi' import { resolveEncryptColumnMap } from '@/encryption/helpers/model-helpers' +import { DATE_LIKE_CASTS } from '@/eql/v3/columns' import type { EncryptedValue } from '@/types' import { logger } from '@/utils/logger' import type { AnyEncryptedTable, EncryptedDynamoDBError } from './types' @@ -483,6 +484,18 @@ 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 { v, encryptConfig, columnPaths, toColumnName } = context @@ -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/operations/bulk-decrypt-models.ts b/packages/stack/src/dynamodb/operations/bulk-decrypt-models.ts index 3da2cf549..9cc477132 100644 --- a/packages/stack/src/dynamodb/operations/bulk-decrypt-models.ts +++ b/packages/stack/src/dynamodb/operations/bulk-decrypt-models.ts @@ -1,4 +1,5 @@ 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 { @@ -52,8 +53,18 @@ export class BulkDecryptModelsOperation< // and the column map are row-invariant. const storedEqlVersion = this.readOptions.storedEqlVersion ?? 3 const readContext = buildReadContext(this.table, storedEqlVersion) - const itemsWithEqlPayloads = this.items.map((item) => - toItemWithEqlPayloads(item, this.table, readContext), + // 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 @@ -66,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/decrypt-model.ts b/packages/stack/src/dynamodb/operations/decrypt-model.ts index 6bc95c36f..f28cf178e 100644 --- a/packages/stack/src/dynamodb/operations/decrypt-model.ts +++ b/packages/stack/src/dynamodb/operations/decrypt-model.ts @@ -1,4 +1,5 @@ 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 { @@ -49,10 +50,16 @@ export class DecryptModelOperation< return await withResult( async () => { 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 @@ -67,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/encryption/index.ts b/packages/stack/src/encryption/index.ts index 7d7256cb4..c4ea6b095 100644 --- a/packages/stack/src/encryption/index.ts +++ b/packages/stack/src/encryption/index.ts @@ -873,7 +873,17 @@ export async function Encryption(config: { ) } - if (clientConfig && Object.hasOwn(clientConfig, 'eqlVersion')) { + // 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.', ) diff --git a/packages/stack/src/types.ts b/packages/stack/src/types.ts index c6485aa12..9b1a8be34 100644 --- a/packages/stack/src/types.ts +++ b/packages/stack/src/types.ts @@ -181,6 +181,13 @@ export type ClientConfig = { * * `Encryption` keeps its runtime guard: JS and JSON callers bypass types * entirely, so this is defence in depth, not a replacement for it. + * + * 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?: never } @@ -247,8 +254,26 @@ export function hasBuildColumnKeyMap<T extends object>( ) } +/** + * 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[], + 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 7e0950d81..c1ad98a1a 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -235,6 +235,11 @@ export type WasmClientConfig = { * 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 @@ -1459,7 +1464,16 @@ export async function Encryption( // 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. - if (clientConfig && Object.hasOwn(clientConfig, 'eqlVersion')) { + // + // 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.', ) diff --git a/skills/stash-dynamodb/SKILL.md b/skills/stash-dynamodb/SKILL.md index a7a68980f..6facd6f96 100644 --- a/skills/stash-dynamodb/SKILL.md +++ b/skills/stash-dynamodb/SKILL.md @@ -488,6 +488,10 @@ 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`, `DynamoDBReadOptions`, `DynamoDBEncryptionClient`, `EncryptedAttributes`, `DecryptedAttributes`, `AuditConfig`.