From 686004f81287c42c51f7e6fe9dfef0f1dd014db2 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 23:28:18 +1000 Subject: [PATCH 1/4] feat(stack)!: EQL v3 audit-on-decrypt + collapse EncryptionV3 into Encryption PR 3 of the EQL v2 removal (#707). Makes EQL v3 the sole generation the client authors and writes, while preserving the minimal v2 READ path on the core client and through the DynamoDB adapter. - Add MappedDecryptOperation: the typed client's decryptModel/bulkDecryptModels are now audit-chainable (.audit()/.withLockContext()) instead of a bare Promise, restoring audited decrypt including through encryptedDynamoDB. - Collapse EncryptionV3 into an overloaded Encryption; EncryptionV3 is now a deprecated, type-identical alias. An explicit config.eqlVersion is honored (the migration escape hatch is retained). - BREAKING: remove the DynamoDB v2 WRITE overloads (encryptModel/bulkEncryptModels); decrypt still reads existing v2 items. - Deprecate (retain) ClientConfig.eqlVersion and the ./schema v2 builders for legacy v2 read/migrate; siblings still consume them, so full removal is deferred to a later PR. - Tests: flip the audit-surface type assertions, add runtime audit-forwarding tests (both chaining orders), and v2-read acceptance integration tests (core + DynamoDB). - Update stash-dynamodb and stash-encryption skills and AGENTS.md. Changesets: @cipherstash/stack minor (audit-on-decrypt), @cipherstash/stack major (DynamoDB v2-write removal), stash patch (skills). --- .changeset/stack-audit-on-decrypt.md | 31 ++++ .changeset/stack-dynamodb-v2-write-removal.md | 17 +++ .changeset/stack-skills-eql-v3-audit.md | 13 ++ AGENTS.md | 4 +- .../decrypt-audit-forwarding.test.ts | 140 ++++++++++++++++++ .../dynamodb/client-compat.test-d.ts | 58 +++++--- .../dynamodb/resolve-decrypt.test.ts | 61 ++++++++ .../stack/__tests__/init-strategy.test.ts | 12 +- .../stack/__tests__/typed-client-v3.test-d.ts | 19 +++ .../stack/__tests__/typed-client-v3.test.ts | 30 +++- .../v3-matrix/matrix-audit.test-d.ts | 27 ++-- .../v3-matrix/matrix-lock-context.test.ts | 8 +- .../schema-v3-client.integration.test.ts | 13 +- .../v2-decrypt-compat.integration.test.ts | 94 ++++++++++++ packages/stack/src/dynamodb/index.ts | 8 +- packages/stack/src/dynamodb/types.ts | 31 +--- packages/stack/src/encryption/index.ts | 51 ++++++- .../encryption/operations/mapped-decrypt.ts | 92 ++++++++++++ packages/stack/src/encryption/v3.ts | 82 +++++----- packages/stack/src/schema/index.ts | 24 +++ packages/stack/src/types.ts | 6 + skills/stash-dynamodb/SKILL.md | 57 +++---- skills/stash-encryption/SKILL.md | 54 +++---- 23 files changed, 752 insertions(+), 180 deletions(-) create mode 100644 .changeset/stack-audit-on-decrypt.md create mode 100644 .changeset/stack-dynamodb-v2-write-removal.md create mode 100644 .changeset/stack-skills-eql-v3-audit.md create mode 100644 packages/stack/__tests__/decrypt-audit-forwarding.test.ts create mode 100644 packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts create mode 100644 packages/stack/src/encryption/operations/mapped-decrypt.ts diff --git a/.changeset/stack-audit-on-decrypt.md b/.changeset/stack-audit-on-decrypt.md new file mode 100644 index 000000000..d832b793d --- /dev/null +++ b/.changeset/stack-audit-on-decrypt.md @@ -0,0 +1,31 @@ +--- +'@cipherstash/stack': minor +--- + +The typed EQL v3 client's `decryptModel` / `bulkDecryptModels` are now +audit-chainable. They return a chainable operation (a `MappedDecryptOperation`) +instead of a bare `Promise>`, so you can attach audit metadata and a +lock context before awaiting: + +```typescript +await client + .decryptModel(item, table) + .audit({ metadata: { requestId } }) + .withLockContext({ identityClaim: ["sub"] }) +``` + +Both chaining orders forward the metadata to ZeroKMS, and the `Date` +reconstruction still applies to the successful result. Await-only call sites are +unchanged — the operation is still thenable to the same `Result`. This restores +audited decrypt through the DynamoDB adapter (`encryptedDynamoDB(...).decryptModel`) +for a v3 client, which previously had nowhere to carry the metadata. + +`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. As part of this collapse +`EncryptionV3` no longer independently pins the wire format — like `Encryption`, +it now honours an explicit `config.eqlVersion` (the retained migration escape +hatch). The `eqlVersion` config field and the `@cipherstash/stack/schema` EQL v2 +builders remain available (now marked `@deprecated`) for reading and migrating +legacy v2 data; the client authors EQL v3 only. Their full removal is deferred to +a later PR. diff --git a/.changeset/stack-dynamodb-v2-write-removal.md b/.changeset/stack-dynamodb-v2-write-removal.md new file mode 100644 index 000000000..0f5058e34 --- /dev/null +++ b/.changeset/stack-dynamodb-v2-write-removal.md @@ -0,0 +1,17 @@ +--- +'@cipherstash/stack': major +--- + +**Breaking (DynamoDB adapter):** `encryptedDynamoDB(...).encryptModel` and +`bulkEncryptModels` no longer accept an EQL v2 table — write is EQL v3 only. The +v2 write type overloads have been removed, narrowing encrypt to `AnyV3Table`. + +**Decrypt still reads existing v2 items.** `decryptModel` / `bulkDecryptModels` +continue to accept an EQL v2 table (`encryptedColumn` / `encryptedField` from +`@cipherstash/stack/schema`), so previously stored v2 DynamoDB items remain +readable — the adapter keeps its v2 envelope reconstruction. Only the v2 write +surface is gone. + +Migrate v2 write call sites to an EQL v3 table (`encryptedTable` + `types.*` from +`@cipherstash/stack/eql/v3`). To keep reading old data, pass the v2 table to the +decrypt methods. diff --git a/.changeset/stack-skills-eql-v3-audit.md b/.changeset/stack-skills-eql-v3-audit.md new file mode 100644 index 000000000..f63f9b085 --- /dev/null +++ b/.changeset/stack-skills-eql-v3-audit.md @@ -0,0 +1,13 @@ +--- +'stash': patch +--- + +Skills refresh for the EQL v3 collapse (ships in the `stash` tarball): + +- `stash-dynamodb`: audited decrypt now works on the typed client — + `client.decryptModel(item, table).audit({ … })` — so the old "use + `Encryption({ config: { eqlVersion: 3 } })` for audited decrypts" caveat is + removed. Encrypt/write is EQL v3 only; decrypt still reads existing v2 items. +- `stash-encryption`: canonical examples use `Encryption` (with `EncryptionV3` + noted as a deprecated alias); the DynamoDB notes state encrypt is v3-only while + decrypt still reads v2. diff --git a/AGENTS.md b/AGENTS.md index 1ae85fb3b..4f29f4fe4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,8 +141,8 @@ Three rules to remember when editing CI or pnpm config: ## Key Concepts and APIs -- **Initialization**: `EncryptionV3({ schemas })` returns the typed EQL v3 client. (`Encryption({ schemas })` is the legacy v2 client.) Provide at least one `encryptedTable`. -- **Schema (EQL v3, the documented approach)**: Define tables/columns with `encryptedTable` and the `types.*` concrete-domain factories from `@cipherstash/stack/eql/v3` (`types.TextSearch`, `types.IntegerOrd`, `types.Json`, …) — each domain's query capabilities are fixed by its type; there are no chainable capability tuners. Build the client with `EncryptionV3` from `@cipherstash/stack/v3`. (Legacy EQL v2 — `Encryption` + `encryptedColumn(...).equality().freeTextSearch().orderAndRange().searchableJson()` from `@cipherstash/stack/schema` — remains for existing deployments. DynamoDB accepts both v3 and v2 tables; nested fields work in both — v2 via `encryptedField` groups, v3 via a flat dotted column path (`'profile.ssn': types.TextEq(...)`), since `EncryptedV3TableColumn` admits no nested groups.) +- **Initialization**: `Encryption({ schemas })` is the single client factory. It is overloaded: an array of concrete EQL v3 tables yields the strongly-typed EQL v3 client; a v2/loose schema set yields the nominal client. `EncryptionV3` (from `@cipherstash/stack/v3`) is a deprecated, type-identical alias of `Encryption`. Provide at least one `encryptedTable`. +- **Schema (EQL v3, the documented approach)**: Define tables/columns with `encryptedTable` and the `types.*` concrete-domain factories from `@cipherstash/stack/eql/v3` (`types.TextSearch`, `types.IntegerOrd`, `types.Json`, …) — each domain's query capabilities are fixed by its type; there are no chainable capability tuners. Build the client with `Encryption` (import `encryptedTable`/`types` from `@cipherstash/stack/v3`). The client authors EQL v3 only; the `config.eqlVersion` field and the `@cipherstash/stack/schema` EQL v2 builders (`encryptedColumn(...).equality().freeTextSearch().orderAndRange().searchableJson()`) remain (now `@deprecated`) for reading/migrating legacy v2 data. `decrypt`/`decryptModel` read both v2 and v3 payloads. DynamoDB **writes EQL v3 only; decrypt still reads existing v2 items**; nested fields work in both — v2 via `encryptedField` groups (read only), v3 via a flat dotted column path (`'profile.ssn': types.TextEq(...)`), since `EncryptedV3TableColumn` admits no nested groups. - **Operations** (all return Result-like objects and support chaining `.withLockContext(lockContext)` and `.audit()` when applicable): - `encrypt(plaintext, { table, column })` - `decrypt(encryptedPayload)` diff --git a/packages/stack/__tests__/decrypt-audit-forwarding.test.ts b/packages/stack/__tests__/decrypt-audit-forwarding.test.ts new file mode 100644 index 000000000..9f3c09288 --- /dev/null +++ b/packages/stack/__tests__/decrypt-audit-forwarding.test.ts @@ -0,0 +1,140 @@ +/** + * Runtime proof that audit metadata attached to the typed EQL v3 client's + * `decryptModel` / `bulkDecryptModels` reaches ZeroKMS — the core half of + * acceptance #2b. Before PR 3 the typed client `await`ed the underlying decrypt + * and mapped the value, which collapsed the chain and dropped `.audit()`; the + * `MappedDecryptOperation` wrapper restores it. + * + * The metadata surfaces as `unverifiedContext` on the mocked protect-ffi + * `decryptBulk` call. Both chaining orders are covered (Risk R3): + * `.audit().withLockContext()` and `.withLockContext().audit()` must each + * forward the metadata (and the lock context's identity claim). + * + * Credential-free: protect-ffi is mocked, so there is no ZeroKMS round-trip. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Encryption } from '@/index' + +// A protect-ffi-shaped encrypted payload so the SDK's `isEncryptedPayload` +// check detects the model field as encrypted and routes it to `decryptBulk`. +const enc = () => ({ v: 2, i: { t: 'users', c: 'email' }, c: 'ciphertext' }) + +vi.mock('@cipherstash/protect-ffi', () => ({ + newClient: vi.fn(async () => ({ __mock: 'client' })), + encrypt: vi.fn(async () => enc()), + decrypt: vi.fn(async () => 'decrypted'), + encryptBulk: vi.fn(async (_c: unknown, opts: { plaintexts: unknown[] }) => + opts.plaintexts.map(enc), + ), + decryptBulk: vi.fn(async (_c: unknown, opts: { ciphertexts: unknown[] }) => + opts.ciphertexts.map(() => 'decrypted'), + ), + decryptBulkFallible: vi.fn( + async (_c: unknown, opts: { ciphertexts: unknown[] }) => + opts.ciphertexts.map(() => ({ data: 'decrypted' })), + ), + encryptQuery: vi.fn(async () => enc()), + encryptQueryBulk: vi.fn(async (_c: unknown, opts: { queries: unknown[] }) => + opts.queries.map(enc), + ), +})) + +import * as ffi from '@cipherstash/protect-ffi' +// Imported after the mock so the v3 table builder is available; `Encryption` +// returns the typed client for an all-v3 schema set. +import { encryptedTable, types } from '@/encryption/v3' + +const users = encryptedTable('users', { + email: types.TextEq('email'), +}) + +const IDENTITY_CLAIM = { identityClaim: ['sub'] } + +// biome-ignore lint/suspicious/noExplicitAny: test helper unwraps Result +function unwrap(result: any) { + if (result.failure) { + throw new Error(`operation failed: ${result.failure.message}`) + } + return result.data +} + +/** Options the FFI decrypt was last called with (second arg). */ +// biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args +const lastDecryptOpts = () => (ffi.decryptBulk as any).mock.calls.at(-1)[1] + +/** The lock context is carried per-ciphertext, not on the top-level opts. */ +const lastCiphertextLockContext = () => + lastDecryptOpts().ciphertexts[0]?.lockContext + +let client: Awaited>> + +beforeEach(async () => { + vi.clearAllMocks() + process.env.CS_WORKSPACE_CRN = 'crn:ap-southeast-2.aws:test-workspace' + client = await Encryption({ schemas: [users] }) +}) + +describe('typed v3 client: audit metadata forwards through decryptModel', () => { + it('forwards .audit({ metadata }) as unverifiedContext (no lock context)', async () => { + unwrap( + await client + .decryptModel({ email: enc() }, users) + .audit({ metadata: { sub: 'u1' } }), + ) + + expect(ffi.decryptBulk).toHaveBeenCalledTimes(1) + expect(lastDecryptOpts().unverifiedContext).toEqual({ sub: 'u1' }) + }) + + it('forwards metadata AND identity claim with .audit().withLockContext()', async () => { + unwrap( + await client + .decryptModel({ email: enc() }, users) + .audit({ metadata: { m: 1 } }) + .withLockContext(IDENTITY_CLAIM), + ) + + expect(ffi.decryptBulk).toHaveBeenCalledTimes(1) + const opts = lastDecryptOpts() + expect(opts.unverifiedContext).toEqual({ m: 1 }) + expect(lastCiphertextLockContext()).toEqual(IDENTITY_CLAIM) + }) + + it('forwards metadata AND identity claim with .withLockContext().audit()', async () => { + unwrap( + await client + .decryptModel({ email: enc() }, users) + .withLockContext(IDENTITY_CLAIM) + .audit({ metadata: { m: 2 } }), + ) + + expect(ffi.decryptBulk).toHaveBeenCalledTimes(1) + const opts = lastDecryptOpts() + expect(opts.unverifiedContext).toEqual({ m: 2 }) + expect(lastCiphertextLockContext()).toEqual(IDENTITY_CLAIM) + }) + + it('forwards metadata via the lockContext argument (no chaining)', async () => { + unwrap( + await client + .decryptModel({ email: enc() }, users, IDENTITY_CLAIM) + .audit({ metadata: { m: 3 } }), + ) + + expect(ffi.decryptBulk).toHaveBeenCalledTimes(1) + const opts = lastDecryptOpts() + expect(opts.unverifiedContext).toEqual({ m: 3 }) + expect(lastCiphertextLockContext()).toEqual(IDENTITY_CLAIM) + }) + + it('forwards .audit({ metadata }) on bulkDecryptModels', async () => { + unwrap( + await client + .bulkDecryptModels([{ email: enc() }], users) + .audit({ metadata: { b: 4 } }), + ) + + expect(ffi.decryptBulk).toHaveBeenCalledTimes(1) + expect(lastDecryptOpts().unverifiedContext).toEqual({ b: 4 }) + }) +}) diff --git a/packages/stack/__tests__/dynamodb/client-compat.test-d.ts b/packages/stack/__tests__/dynamodb/client-compat.test-d.ts index 2c322357a..971b641a2 100644 --- a/packages/stack/__tests__/dynamodb/client-compat.test-d.ts +++ b/packages/stack/__tests__/dynamodb/client-compat.test-d.ts @@ -79,27 +79,25 @@ describe('encryptedDynamoDB accepts both client shapes without a cast', () => { const dynamo = encryptedDynamoDB({ encryptionClient: typedClient }) -describe('all four methods accept both a v3 and a v2 table', () => { - it('encryptModel', () => { +describe('write is EQL v3 only; read accepts both a v3 and a v2 table', () => { + it('encryptModel accepts a v3 table and rejects a v2 table', () => { expectTypeOf(dynamo.encryptModel).toBeCallableWith( { pk: 'a', email: 'a@b.com' }, usersV3, ) - expectTypeOf(dynamo.encryptModel).toBeCallableWith( - { pk: 'a', email: 'a@b.com' }, - usersV2, - ) + // Write is EQL v3 only — the v2 write overload was removed, so a v2 table is + // rejected on encrypt (decrypt below still accepts it). + // @ts-expect-error - encryptModel no longer accepts an EQL v2 table + dynamo.encryptModel({ pk: 'a', email: 'a@b.com' }, usersV2) }) - it('bulkEncryptModels', () => { + it('bulkEncryptModels accepts a v3 table and rejects a v2 table', () => { expectTypeOf(dynamo.bulkEncryptModels).toBeCallableWith( [{ pk: 'a', email: 'a@b.com' }], usersV3, ) - expectTypeOf(dynamo.bulkEncryptModels).toBeCallableWith( - [{ pk: 'a', email: 'a@b.com' }], - usersV2, - ) + // @ts-expect-error - bulkEncryptModels no longer accepts an EQL v2 table + dynamo.bulkEncryptModels([{ pk: 'a', email: 'a@b.com' }], usersV2) }) it('decryptModel', () => { @@ -332,18 +330,6 @@ describe('a required-nullable v3 column keeps __source required', () => { }) }) -describe('the v2 overload still returns the input model', () => { - it('keeps an existing v2 caller compiling unchanged', async () => { - const result = await dynamo.encryptModel<{ pk: string; email?: string }>( - { pk: 'a', email: 'a@b.com' }, - usersV2, - ) - if (result.failure) throw new Error(result.failure.message) - - expectTypeOf(result.data).toEqualTypeOf<{ pk: string; email?: string }>() - }) -}) - describe('operations chain and resolve', () => { it('.audit() returns the operation so it stays chainable', () => { const op = dynamo.encryptModel({ pk: 'a', email: 'a@b.com' }, usersV3) @@ -352,6 +338,32 @@ describe('operations chain and resolve', () => { >() }) + it('.audit() is chainable on decryptModel and returns the operation', () => { + // The DynamoDB decrypt op is chainable; audit metadata now forwards to the + // underlying client decrypt regardless of client shape (see + // resolve-decrypt.test.ts / decrypt-audit-forwarding.test.ts for the runtime + // proof). Here we lock the type-level surface. + const op = dynamo.decryptModel({ pk: 'a', email__source: 'ct' }, usersV3) + expectTypeOf(op).toHaveProperty('audit') + expectTypeOf(op.audit({ metadata: { sub: 'u1' } })).toEqualTypeOf< + typeof op + >() + }) + + it('awaiting decryptModel yields a discriminated Result', async () => { + const result = await dynamo.decryptModel( + { pk: 'a', email__source: 'ct' }, + usersV3, + ) + + if (result.failure) { + expectTypeOf(result.failure.message).toEqualTypeOf() + return + } + + expectTypeOf(result.data.email).toEqualTypeOf() + }) + it('awaiting yields a discriminated Result', async () => { const result = await dynamo.encryptModel( { pk: 'a', email: 'a@b.com' }, diff --git a/packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts b/packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts index f742afb9d..8d5eb2b1b 100644 --- a/packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts +++ b/packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts @@ -5,8 +5,12 @@ * reachable through a live ZeroKMS decrypt; these move that assurance onto the * pure CI lane. No credentials, no network. */ +import type { Result } from '@byteslice/result' import { afterEach, describe, expect, it, vi } from 'vitest' import { resolveDecryptResult, throwPreservingCode } from '@/dynamodb/helpers' +import { EncryptionOperation } from '@/encryption/operations/base-operation' +import { MappedDecryptOperation } from '@/encryption/operations/mapped-decrypt' +import { type EncryptionError, EncryptionErrorTypes } from '@/errors' import { logger } from '@/utils/logger' // The metadata-drop tests `vi.spyOn(logger, 'debug')` — the same shared singleton @@ -92,6 +96,63 @@ describe('resolveDecryptResult', () => { spy.mockRestore() } }) + + it('forwards audit metadata through a MappedDecryptOperation and applies its map', async () => { + // The typed EQL v3 client returns a `MappedDecryptOperation` on decrypt. + // resolveDecryptResult sees its `.audit()` and chains it; the wrapper + // forwards the metadata to the underlying op (whose `execute` reads it) and + // maps the successful result. This is the DynamoDB half of acceptance #2b. + let seenMetadata: Record | undefined + class Underlying extends EncryptionOperation<{ v: number }> { + override async execute(): Promise< + Result<{ v: number }, EncryptionError> + > { + seenMetadata = this.getAuditData().metadata + return { data: { v: 1 } } + } + } + + const mapped = new MappedDecryptOperation< + { v: number }, + { mapped: number } + >(new Underlying(), (value) => ({ mapped: value.v + 1 }), { + failure: { + type: EncryptionErrorTypes.DecryptionError, + message: 'unknown table', + }, + }) + + const result = await resolveDecryptResult(mapped, { metadata: { m: 7 } }) + + expect(result).toEqual({ data: { mapped: 2 } }) + expect(seenMetadata).toEqual({ m: 7 }) + }) + + it('returns the precomputed failure from a MappedDecryptOperation with no map (unknown table)', async () => { + class Underlying extends EncryptionOperation<{ v: number }> { + override async execute(): Promise< + Result<{ v: number }, EncryptionError> + > { + return { data: { v: 1 } } + } + } + + const unknownTableFailure = { + failure: { + type: EncryptionErrorTypes.DecryptionError, + message: 'unknown table', + }, + } + const mapped = new MappedDecryptOperation< + { v: number }, + { mapped: number } + >(new Underlying(), undefined, unknownTableFailure) + + const result = await resolveDecryptResult(mapped, { metadata: { m: 7 } }) + + expect(result.failure?.message).toBe('unknown table') + expect(result.data).toBeUndefined() + }) }) describe('throwPreservingCode', () => { diff --git a/packages/stack/__tests__/init-strategy.test.ts b/packages/stack/__tests__/init-strategy.test.ts index ddef9f0f5..1b29b4359 100644 --- a/packages/stack/__tests__/init-strategy.test.ts +++ b/packages/stack/__tests__/init-strategy.test.ts @@ -254,15 +254,17 @@ describe('Encryption config.eqlVersion', () => { expect(lastNewClientOpts().eqlVersion).toBe(3) }) - it('EncryptionV3 overrides an explicit eqlVersion — v3 is an invariant', async () => { - // A v2-mode client cannot resolve v3 concrete-type columns (every encrypt - // fails inside the FFI), so EncryptionV3 pins the wire format rather than - // honouring a caller's eqlVersion. + it('EncryptionV3 is a deprecated alias of Encryption — it honours an explicit eqlVersion identically', async () => { + // Post-collapse `EncryptionV3` IS `Encryption` (a deprecated alias), so it no + // longer independently pins the wire format: an explicit `eqlVersion: 2` over + // a v3 schema set is honoured exactly as `Encryption` honours it (the + // migration escape hatch above). A v2-mode client still cannot encrypt v3 + // concrete-type columns — this only pins the wire flag reaching the FFI. await EncryptionV3({ schemas: [v3Table() as never], config: { eqlVersion: 2 }, }) - expect(lastNewClientOpts().eqlVersion).toBe(3) + expect(lastNewClientOpts().eqlVersion).toBe(2) }) }) diff --git a/packages/stack/__tests__/typed-client-v3.test-d.ts b/packages/stack/__tests__/typed-client-v3.test-d.ts index 03b929c9b..58419bd1b 100644 --- a/packages/stack/__tests__/typed-client-v3.test-d.ts +++ b/packages/stack/__tests__/typed-client-v3.test-d.ts @@ -146,6 +146,25 @@ describe('typed v3 client — model decrypt yields precise plaintext', () => { users, ) }) + + it('decryptModel / bulkDecryptModels are chainable with .audit() and .withLockContext()', () => { + // The typed client's decrypt methods return a chainable operation (not a bare + // Promise), so audit metadata and lock context can be attached before await. + const op = client.decryptModel({ id: 'u1', email: {} as Encrypted }, users) + expectTypeOf(op).toHaveProperty('audit') + expectTypeOf(op).toHaveProperty('withLockContext') + // Both stay chainable — same operation type back. + expectTypeOf(op.audit({ metadata: { sub: 'u1' } })).toEqualTypeOf< + typeof op + >() + + const bulkOp = client.bulkDecryptModels( + [{ id: 'u1', email: {} as Encrypted }], + users, + ) + expectTypeOf(bulkOp).toHaveProperty('audit') + expectTypeOf(bulkOp).toHaveProperty('withLockContext') + }) }) describe('typed v3 client — soundness', () => { diff --git a/packages/stack/__tests__/typed-client-v3.test.ts b/packages/stack/__tests__/typed-client-v3.test.ts index 5969989b7..457fc8378 100644 --- a/packages/stack/__tests__/typed-client-v3.test.ts +++ b/packages/stack/__tests__/typed-client-v3.test.ts @@ -11,14 +11,32 @@ const table = encryptedTable('t', { }) /** - * A minimal client stub whose model-decrypt methods resolve to a fixed - * `Result` payload. `typedClient` only `await`s these, so a plain Promise is a - * sufficient thenable. + * A minimal operation stub resolving to a fixed `Result`. `typedClient` now + * wraps the underlying decrypt op in a `MappedDecryptOperation` and calls + * `.execute()` on it (rather than awaiting a bare promise), so the stub must be + * operation-like: `.execute()` plus the chainable `.audit()` / `.withLockContext()` + * the wrapper may delegate to. + */ +function fakeOp(result: R) { + return { + execute: () => Promise.resolve(result), + audit() { + return this + }, + withLockContext() { + return this + }, + } +} + +/** + * A minimal client stub whose model-decrypt methods return an operation + * resolving to a fixed `Result` payload. */ function fakeClient(data: Record): EncryptionClient { return { - decryptModel: () => Promise.resolve({ data }), - bulkDecryptModels: () => Promise.resolve({ data: [data] }), + decryptModel: () => fakeOp({ data }), + bulkDecryptModels: () => fakeOp({ data: [data] }), } as unknown as EncryptionClient } @@ -77,7 +95,7 @@ describe('typedClient — decrypt reconstruction', () => { it('propagates a failure result unchanged', async () => { const failing = { decryptModel: () => - Promise.resolve({ + fakeOp({ failure: { type: 'DecryptionError', message: 'boom' }, }), } as unknown as EncryptionClient diff --git a/packages/stack/__tests__/v3-matrix/matrix-audit.test-d.ts b/packages/stack/__tests__/v3-matrix/matrix-audit.test-d.ts index cdaa95db9..e3b56d954 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-audit.test-d.ts +++ b/packages/stack/__tests__/v3-matrix/matrix-audit.test-d.ts @@ -1,9 +1,10 @@ /** - * Type-level pin of a real v3 asymmetry: audit metadata is available on the - * encrypt-side operations (which are chainable) but NOT on `decryptModel` / - * `bulkDecryptModels`, which return a bare `Promise>` rather than a - * chainable operation. Documented here as an executable invariant so the gap - * (v2's `decryptModel().audit(...)` has no v3 equivalent) can't silently change. + * Type-level pin that audit + lock-context are chainable on BOTH the encrypt-side + * operations AND `decryptModel` / `bulkDecryptModels`. The typed client's decrypt + * methods now return a chainable {@link MappedDecryptOperation} (was a bare + * `Promise>`), restoring the v2-era `decryptModel().audit(...)` surface + * on the v3 client. Documented here as an executable invariant so the capability + * can't silently regress. * * Runs via `pnpm test:types`. */ @@ -22,10 +23,16 @@ describe('v3 typed client audit/lock-context chainability (types)', () => { expectTypeOf(op).toHaveProperty('withLockContext') }) - it('does NOT expose .audit()/.withLockContext() on decryptModel (bare Promise)', () => { - const result = typed.decryptModel({ email: {} as never }, users) - // A Promise, not a chainable operation — no audit/lock-context hook. - expectTypeOf(result).not.toHaveProperty('audit') - expectTypeOf(result).not.toHaveProperty('withLockContext') + it('exposes .audit()/.withLockContext() on decryptModel (chainable operation)', () => { + const op = typed.decryptModel({ email: {} as never }, users) + // A chainable operation, not a bare Promise — audit + lock-context hooks. + expectTypeOf(op).toHaveProperty('audit') + expectTypeOf(op).toHaveProperty('withLockContext') + }) + + it('exposes .audit()/.withLockContext() on bulkDecryptModels (chainable operation)', () => { + const op = typed.bulkDecryptModels([{ email: {} as never }], users) + expectTypeOf(op).toHaveProperty('audit') + expectTypeOf(op).toHaveProperty('withLockContext') }) }) diff --git a/packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts b/packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts index d8041e338..e7456b947 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 { encryptedTable, typedClient, types } from '@/encryption/v3' +import { encryptedTable, types } from '@/encryption/v3' const users = encryptedTable('users', { email: types.TextEq('email'), @@ -68,14 +68,16 @@ function unwrap(result: any) { // biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args const lastOpts = (fn: any) => fn.mock.calls.at(-1)[1] -let typed: ReturnType +// `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: Awaited>> let prevWorkspaceCrn: string | undefined beforeEach(async () => { vi.clearAllMocks() prevWorkspaceCrn = process.env.CS_WORKSPACE_CRN process.env.CS_WORKSPACE_CRN = 'crn:ap-southeast-2.aws:test-workspace' - typed = typedClient(await Encryption({ schemas: [users] as never }), users) + typed = await Encryption({ schemas: [users] }) }) afterEach(() => { diff --git a/packages/stack/integration/shared/schema-v3-client.integration.test.ts b/packages/stack/integration/shared/schema-v3-client.integration.test.ts index 839ded21a..a3b31b3a5 100644 --- a/packages/stack/integration/shared/schema-v3-client.integration.test.ts +++ b/packages/stack/integration/shared/schema-v3-client.integration.test.ts @@ -1,7 +1,6 @@ import { unwrapResult } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' import type { EncryptionClient } from '@/encryption' -import { typedClient } from '@/encryption/v3' import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' @@ -173,7 +172,9 @@ describe('eql_v3 client integration', () => { // `Date` from the encrypt-config `cast_as` (`reconstructRow`), keyed by the // JS property (`createdOn`) even though the DB column is `created_on`. it('round-trips a representative date storage domain via decryptModel', async () => { - const typed = typedClient(protectClient, users) + // `Encryption` returns the typed client for an all-v3 schema set, so + // `protectClient` already carries the typed model methods — no extra wrap. + const typed = protectClient // Zero milliseconds so the FFI dropping sub-second precision (`...00Z` vs // `...000Z`) does not perturb the reconstructed instant. const day = new Date('2026-07-01T00:00:00.000Z') @@ -203,7 +204,9 @@ describe('eql_v3 client integration', () => { // ENCRYPTED by the model path — not silently passed through as plaintext // because the field key (`createdOn`) fails to match the DB-keyed config. it('encrypts a property-vs-DB-name column through encryptModel (no plaintext leak)', async () => { - const typed = typedClient(protectClient, users) + // `Encryption` returns the typed client for an all-v3 schema set, so + // `protectClient` already carries the typed model methods — no extra wrap. + const typed = protectClient const day = new Date('2026-07-01T00:00:00.000Z') const encrypted = unwrapResult( @@ -232,7 +235,9 @@ describe('eql_v3 client integration', () => { // ms-zeroed `12:34:56` value must round-trip exactly. (Was skipped while every // timestamp domain wrongly set `cast_as: 'date'`; re-enabled with that fix.) it('round-trips a timestamp occurredAt column through the model path', async () => { - const typed = typedClient(protectClient, users) + // `Encryption` returns the typed client for an all-v3 schema set, so + // `protectClient` already carries the typed model methods — no extra wrap. + const typed = protectClient // Zero milliseconds: the FFI drops sub-second precision, so a ms-bearing // instant would perturb the reconstructed value. const moment = new Date('2026-07-01T12:34:56.000Z') diff --git a/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts b/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts new file mode 100644 index 000000000..46567cfaa --- /dev/null +++ b/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts @@ -0,0 +1,94 @@ +/** + * Acceptance #1a + #1b — EQL v2 READ compatibility after the v3 collapse (PR 3). + * + * PR 3 makes EQL v3 the only generation the client authors/writes, 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'`). + * + * Both fail if the v2 read path is removed. Live: requires real ZeroKMS + * credentials (the integration harness provisions them and throws otherwise). + */ +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 { 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(), +}) + +const SECRET = 'ada@example.com' + +// A v2-mode client: the explicit `eqlVersion: 2` escape hatch forces v2 wire so +// this suite mints genuinely-v2 payloads, independent of schema auto-detection. +let v2Client: EncryptionClient + +beforeAll(async () => { + v2Client = await Encryption({ + schemas: [usersV2], + config: { eqlVersion: 2 }, + }) +}) + +describe('#1a — core client reads a stored EQL v2 payload', () => { + it('round-trips a v2 ciphertext through decrypt', async () => { + const encrypted = unwrapResult( + await v2Client.encrypt(SECRET, { table: usersV2, column: usersV2.email }), + ) + // Guard against a false pass: it must be an actual ciphertext. + expect(encrypted).toHaveProperty('c') + + const decrypted = unwrapResult(await v2Client.decrypt(encrypted)) + expect(decrypted).toBe(SECRET) + }, 30000) + + it('round-trips a v2 model through decryptModel', async () => { + const encryptedModel = unwrapResult( + await v2Client.encryptModel({ pk: 'a', email: SECRET }, usersV2), + ) + expect(encryptedModel.email).toHaveProperty('c') + + const decrypted = unwrapResult(await v2Client.decryptModel(encryptedModel)) + expect(decrypted).toEqual({ pk: 'a', email: SECRET }) + }, 30000) +}) + +describe('#1b — DynamoDB adapter reads a stored EQL v2 item', () => { + it('decrypts a v2-split DynamoDB item via encryptedDynamoDB.decryptModel', async () => { + // Stage a stored v2 item: encrypt a v2 model, then split it into DynamoDB + // attributes exactly as the (removed-at-the-type-level, retained-at-runtime) + // v2 write path would have — `toEncryptedDynamoItem(..., false)`. + const encryptedModel = unwrapResult( + await v2Client.encryptModel({ pk: 'a', email: SECRET }, usersV2), + ) + const storedV2Item = toEncryptedDynamoItem(encryptedModel, ['email'], false) + + // The email column becomes `email__source` (+ `email__hmac` for equality); + // the plaintext key does not survive the split. + expect(storedV2Item).toHaveProperty('email__source') + expect(storedV2Item).not.toHaveProperty('email') + expect(storedV2Item.pk).toBe('a') + + const dynamo = encryptedDynamoDB({ encryptionClient: v2Client }) + const decrypted = unwrapResult( + await dynamo.decryptModel(storedV2Item, usersV2), + ) + + expect(decrypted).toMatchObject({ pk: 'a', email: SECRET }) + }, 30000) +}) diff --git a/packages/stack/src/dynamodb/index.ts b/packages/stack/src/dynamodb/index.ts index bce47df8b..9e2c60aad 100644 --- a/packages/stack/src/dynamodb/index.ts +++ b/packages/stack/src/dynamodb/index.ts @@ -68,9 +68,11 @@ function assertClientTableVersionMatch( * and `bulkDecryptModels` methods that transparently encrypt/decrypt DynamoDB * items according to the provided table schema. * - * Accepts EQL v3 tables (`types.*` domains) and EQL v2 tables - * (`encryptedColumn`/`encryptedField`) alike — the table decides which wire - * format is synthesized on read. + * **Encrypt/write is EQL v3 only** — `encryptModel` / `bulkEncryptModels` accept + * only EQL v3 tables (`types.*` domains). **Decrypt still reads existing EQL v2 + * items**: `decryptModel` / `bulkDecryptModels` continue to accept an EQL v2 + * table (`encryptedColumn`/`encryptedField`) so previously stored v2 data + * remains readable. The table decides which wire format is reconstructed on read. * * Only equality is meaningful on DynamoDB: an `hm` term is stored alongside the * ciphertext as `__hmac` and can back a key condition. Ordering and diff --git a/packages/stack/src/dynamodb/types.ts b/packages/stack/src/dynamodb/types.ts index 1bfbca989..d661928a3 100644 --- a/packages/stack/src/dynamodb/types.ts +++ b/packages/stack/src/dynamodb/types.ts @@ -37,12 +37,12 @@ export type AnyEncryptedTable = * nominal `TypedEncryptionClient` parameter would reject a client built for * a narrower schema tuple. * - * The two clients differ at runtime on the decrypt paths — the nominal client - * returns a chainable operation carrying `.audit()`, the typed wrapper returns - * a plain `Promise>` and takes the table as a second argument. The - * operation classes handle both; see `DecryptModelOperation`. The consequence - * for callers is that **audit metadata on decrypt requires the nominal - * client** — with a client from `EncryptionV3` there is nowhere to put it. + * Both clients now return a chainable operation on the decrypt paths — 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`. Audit metadata on decrypt + * is therefore forwarded regardless of which client shape is supplied. */ export type DynamoDBEncryptionClient = { encryptModel(input: never, table: never): unknown @@ -169,8 +169,8 @@ type Simplify = { [K in keyof T]: T[K] } * * A declared column `email` does NOT survive as `email`: the adapter deletes it * and writes `email__source` (plus `email__hmac` for equality domains). Typing - * the result as the input model — what the v2 overload still does — is a lie - * that type-checks `result.data.email` (always `undefined` at runtime) and + * the result as the input model — what the removed v2 write overload did — is a + * lie that type-checks `result.data.email` (always `undefined` at runtime) and * rejects `result.data.email__source` (the value you actually want). * * Keys that name no column pass through untouched — partition/sort keys, GSI @@ -240,16 +240,6 @@ export interface EncryptedDynamoDBInstance { item: V3ModelInput, table: Table, ): EncryptModelOperation> - /** - * EQL v2. Unchanged, so existing callers keep compiling — v2 columns do not - * carry their index configuration in the type, so the storage split cannot be - * derived. The returned `T` is the INPUT model, not what is on the wire; read - * `__source` / `__hmac` through a type of your own. - */ - encryptModel>( - item: T, - table: EncryptedTable, - ): EncryptModelOperation /** EQL v3. See {@link EncryptedDynamoDBInstance.encryptModel}. */ bulkEncryptModels< @@ -259,11 +249,6 @@ export interface EncryptedDynamoDBInstance { items: Array>, table: Table, ): BulkEncryptModelsOperation> - /** EQL v2. See {@link EncryptedDynamoDBInstance.encryptModel}. */ - bulkEncryptModels>( - items: T[], - table: EncryptedTable, - ): BulkEncryptModelsOperation /** * EQL v3: `item` is the stored attribute map (`__source` / diff --git a/packages/stack/src/encryption/index.ts b/packages/stack/src/encryption/index.ts index a66817e79..968bea2e1 100644 --- a/packages/stack/src/encryption/index.ts +++ b/packages/stack/src/encryption/index.ts @@ -1,6 +1,7 @@ import { type Result, withResult } from '@byteslice/result' import { newClient } from '@cipherstash/protect-ffi' import { validate as uuidValidate } from 'uuid' +import type { AnyV3Table } from '@/eql/v3' import { type EncryptionError, EncryptionErrorTypes } from '@/errors' // `LockContext` is imported type-only so the TSDoc {@link} references in the // comments below resolve; it is erased at compile time. @@ -20,6 +21,7 @@ import type { BulkDecryptPayload, BulkEncryptPayload, Client, + ClientConfig, Encrypted, EncryptedFromBuildableTable, EncryptionClientConfig, @@ -43,6 +45,13 @@ import { DecryptModelOperation } from './operations/decrypt-model' import { EncryptOperation } from './operations/encrypt' import { EncryptModelOperation } from './operations/encrypt-model' import { EncryptQueryOperation } from './operations/encrypt-query' +// `typedClient` wraps the nominal client into the strongly-typed EQL v3 client. +// This is a deliberate circular import with `./v3` (which imports `Encryption` +// from here): both `Encryption` and `typedClient` are hoisted `function` +// declarations, and the only module-eval cross-reference — `EncryptionV3 = +// Encryption` in `./v3` — reads the hoisted `Encryption`, so neither binding is +// observed uninitialised regardless of which module evaluates first. +import { type TypedEncryptionClient, typedClient } from './v3' // Re-export the operation classes returned by EncryptionClient methods so they // are part of the public API and appear in the generated reference, allowing @@ -852,9 +861,24 @@ export function __resetStrategyDeprecationWarningForTests(): void { * @see {@link ClientConfig.authStrategy} for the auth strategy field. * @see {@link EncryptionClient} for available methods after initialization. */ -export const Encryption = async ( +// 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. +export function Encryption(config: { + schemas: S + config?: ClientConfig +}): Promise> +// 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` / `ReturnType` resolve +// to this signature (stack-supabase casts its schemas against it). +export function Encryption( config: EncryptionClientConfig, -): Promise => { +): Promise +export async function Encryption( + config: EncryptionClientConfig, +): Promise { const { schemas, config: clientConfig } = config if (!schemas.length) { @@ -885,10 +909,16 @@ export const Encryption = async ( const client = new EncryptionClient() const encryptConfig = buildEncryptConfig(...schemas) - // Resolve the wire format for this client: an explicit - // `config.eqlVersion` wins; otherwise it is auto-detected from the - // schema set (v3 tables → 3, v2 tables → the FFI's v2 default). A mixed - // v2 + v3 schema set throws — one client emits exactly one wire format. + // A schema set of exclusively concrete EQL v3 tables is the typed-client path + // (the collapse of the former `EncryptionV3`). Every other set — EQL v2 tables, + // or the introspection-derived loose tables stack-supabase passes — stays + // nominal. + const isV3Only = schemas.every(hasBuildColumnKeyMap) + + // Resolve the wire format: an explicit `config.eqlVersion` wins (the retained + // migration escape hatch — e.g. deliberately writing v2 wire from a v3 schema + // set), otherwise it is auto-detected (v3 tables → 3, v2 tables → the FFI's v2 + // default). A mixed v2 + v3 schema set throws inside `resolveEqlVersion`. const eqlVersion = resolveEqlVersion(schemas, clientConfig?.eqlVersion) const result = await client.init({ @@ -902,5 +932,12 @@ export const Encryption = async ( throw new Error(`[encryption]: ${result.failure.message}`) } - return result.data + // 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. A caller that forced + // `eqlVersion: 2` over v3 schemas gets the nominal client for that deliberate, + // low-level v2-wire migration path (the typed client cannot encrypt v3 columns + // in v2 mode anyway). + return isV3Only && eqlVersion === 3 + ? typedClient(result.data, ...(schemas as unknown as readonly AnyV3Table[])) + : result.data } diff --git a/packages/stack/src/encryption/operations/mapped-decrypt.ts b/packages/stack/src/encryption/operations/mapped-decrypt.ts new file mode 100644 index 000000000..378b30ab4 --- /dev/null +++ b/packages/stack/src/encryption/operations/mapped-decrypt.ts @@ -0,0 +1,92 @@ +import type { Result } from '@byteslice/result' +import type { EncryptionError } from '@/errors' +import type { LockContextInput } from '@/identity' +import { type AuditConfig, EncryptionOperation } from './base-operation' + +/** + * The subset of an underlying decrypt operation the mapped wrapper drives: a + * chainable {@link EncryptionOperation} that MAY additionally expose + * `.withLockContext()`. A lock-bound underlying op (e.g. + * `DecryptModelOperationWithLockContext`) has already consumed its lock context + * and offers no further `withLockContext`, so the method is optional here. + */ +type UnderlyingDecryptOperation = EncryptionOperation & { + withLockContext?: (lockContext: LockContextInput) => EncryptionOperation +} + +/** + * The public contract of a decrypt-model operation returned by the typed client: + * awaitable to a `Result`, and chainable with `.audit()` / + * `.withLockContext()`. Hides the underlying pre-map type parameter. + */ +export interface AuditableDecryptModelOperation + extends EncryptionOperation { + audit(config: AuditConfig): this + withLockContext( + lockContext: LockContextInput, + ): AuditableDecryptModelOperation +} + +/** + * A chainable decrypt operation that maps a successful result through a pure, + * precomputed function while delegating audit metadata and lock context to the + * underlying operation it wraps. + * + * This is what lets the typed EQL v3 client carry `.audit()` / + * `.withLockContext()` on `decryptModel` / `bulkDecryptModels`: the earlier + * implementation `await`ed the underlying op and mapped the resolved value, + * which collapsed the chain to a plain `Promise>` and dropped both + * capabilities. Here the mapping runs inside `execute()` instead, so the + * operation stays an {@link EncryptionOperation} the whole way. + * + * - `.audit()` forwards to the underlying op, so the op that actually runs the + * decrypt sees the metadata (via `getAuditData()`), in EITHER chaining order — + * `.audit().withLockContext()` propagates because `withLockContext` copies the + * audit data forward, and `.withLockContext().audit()` propagates because the + * wrapper forwards `.audit()` onto the now-lock-bound underlying op. + * - `.withLockContext()` rebuilds the wrapper over `underlying.withLockContext(lc)`, + * preserving the same `map` and unknown-table failure. + * - `execute()` never throws: an unknown table (no `map`) returns the precomputed + * `failure` Result, and `map` is a precomputed reconstructor — pure, no + * `build()` — so it cannot reject the Result contract. + */ +export class MappedDecryptOperation extends EncryptionOperation { + constructor( + private readonly underlying: UnderlyingDecryptOperation, + // Precomputed reconstructor. `undefined` when the table is unknown to the + // client — `execute()` then short-circuits to `unknownTableFailure`. + private readonly map: ((value: In) => Out) | undefined, + private readonly unknownTableFailure: { failure: EncryptionError }, + ) { + super() + } + + override audit(config: AuditConfig): this { + // Delegate to the op that runs the decrypt so its `execute()` sees the + // metadata; the wrapper's own `execute()` reads nothing off `this`. + this.underlying.audit(config) + return this + } + + withLockContext( + lockContext: LockContextInput, + ): MappedDecryptOperation { + // A lock-bound underlying op exposes no `withLockContext`; there is nothing + // to re-bind, so keep the current underlying op. + const bound = this.underlying.withLockContext + ? this.underlying.withLockContext(lockContext) + : this.underlying + return new MappedDecryptOperation(bound, this.map, this.unknownTableFailure) + } + + override async execute(): Promise> { + if (!this.map) { + return this.unknownTableFailure + } + const result = await this.underlying.execute() + if (result.failure) { + return result + } + return { data: this.map(result.data) } + } +} diff --git a/packages/stack/src/encryption/v3.ts b/packages/stack/src/encryption/v3.ts index 14d934556..34c4602c8 100644 --- a/packages/stack/src/encryption/v3.ts +++ b/packages/stack/src/encryption/v3.ts @@ -1,4 +1,3 @@ -import type { Result } from '@byteslice/result' import type { AnyV3Table, ColumnsOf, @@ -15,7 +14,6 @@ import type { LockContextInput } from '@/identity' import type { BulkDecryptPayload, BulkEncryptPayload, - ClientConfig, Encrypted, EncryptedReturnType, EncryptOptions, @@ -33,6 +31,10 @@ import { type EncryptOperation, type EncryptQueryOperation, } from './index' +import { + type AuditableDecryptModelOperation, + MappedDecryptOperation, +} from './operations/mapped-decrypt' /** * A strongly-typed view over an {@link EncryptionClient} for EQL v3 schemas. @@ -101,22 +103,24 @@ export interface TypedEncryptionClient { * columns are reconstructed from the encrypt-config `cast_as`. * * Pass `lockContext` to decrypt identity-bound data — the same context that - * was supplied at encrypt time must be provided here. + * was supplied at encrypt time must be provided here (or chain + * `.withLockContext()` on the returned operation). * - * Unlike the encrypt operations this returns a plain `Promise>` - * rather than a chainable operation, because it maps the resolved value. + * Returns a chainable {@link AuditableDecryptModelOperation}: await it for the + * `Result`, or chain `.audit({ metadata })` / `.withLockContext()` first. The + * per-row `Date` reconstruction is applied to the successful result. */ decryptModel>( input: T, table: Table, lockContext?: LockContextInput, - ): Promise, EncryptionError>> + ): AuditableDecryptModelOperation> bulkDecryptModels
>( input: Array, table: Table, lockContext?: LockContextInput, - ): Promise>, EncryptionError>> + ): AuditableDecryptModelOperation>> // Parity passthroughs — not v3-strengthened, delegated as-is. bulkEncrypt( @@ -253,25 +257,31 @@ export function typedClient( bulkEncryptModels: (input, table) => client.bulkEncryptModels(input as never, table as never) as never, decrypt: (encrypted) => client.decrypt(encrypted), - decryptModel: async (input, table, lockContext) => { + decryptModel: (input, table, lockContext) => { + // `reconstruct` is undefined for a table this client was not initialized + // with; the mapped op then resolves to `unknownTableFailure` on execute. const reconstruct = reconstructors.get(table.tableName) - if (!reconstruct) return unknownTableFailure as never const op = client.decryptModel(input as never) - const result = await (lockContext ? op.withLockContext(lockContext) : op) - if (result.failure) return result as never - return { data: reconstruct(result.data) } as never + const base = lockContext ? op.withLockContext(lockContext) : op + return new MappedDecryptOperation( + base, + reconstruct, + unknownTableFailure, + ) as never }, - bulkDecryptModels: async (input, table, lockContext) => { + bulkDecryptModels: (input, table, lockContext) => { const reconstruct = reconstructors.get(table.tableName) - if (!reconstruct) return unknownTableFailure as never const op = client.bulkDecryptModels(input as never) - const result = await (lockContext ? op.withLockContext(lockContext) : op) - if (result.failure) return result as never - return { - data: result.data.map((row) => - reconstruct(row as Record), - ), - } as never + const base = lockContext ? op.withLockContext(lockContext) : op + // The underlying op resolves to an array of rows; reconstruct each. + const mapRows = reconstruct + ? (rows: Array>) => rows.map(reconstruct) + : undefined + return new MappedDecryptOperation( + base, + mapRows, + unknownTableFailure, + ) as never }, bulkEncrypt: (plaintexts, opts) => client.bulkEncrypt(plaintexts, opts), bulkDecrypt: (payloads) => client.bulkDecrypt(payloads), @@ -280,38 +290,22 @@ export function typedClient( } /** - * Build a {@link TypedEncryptionClient} for EQL v3 schemas — the strongly-typed - * counterpart to {@link Encryption}. Mirrors its config, then retypes the client - * against the provided v3 `schemas`. + * @deprecated Use {@link Encryption} instead — it is now overloaded so an array + * of concrete EQL v3 tables yields the same strongly-typed client this used to. + * `EncryptionV3` is a type-identical alias of `Encryption`, retained for + * backwards compatibility, and will be removed in a future release. * * @example * ```typescript - * import { EncryptionV3, encryptedTable, types } from "@cipherstash/stack/v3" + * import { Encryption, encryptedTable, types } from "@cipherstash/stack/v3" * * const users = encryptedTable("users", { email: types.TextSearch("email") }) - * const client = await EncryptionV3({ schemas: [users] }) + * const client = await Encryption({ schemas: [users] }) * * await client.encrypt("a@b.com", { table: users, column: users.email }) * ``` */ -export async function EncryptionV3< - const S extends readonly AnyV3Table[], ->(config: { - schemas: S - config?: ClientConfig -}): Promise> { - const client = await Encryption({ - schemas: config.schemas as unknown as Parameters< - typeof Encryption - >[0]['schemas'], - // Force the v3 EQL wire format. protect-ffi's newClient defaults to - // eqlVersion 2; a v2-mode client cannot resolve v3 concrete-type columns - // and fails every encrypt with "Cannot convert undefined or null to - // object". This is a v3-only invariant, so it overrides any user value. - config: { ...config.config, eqlVersion: 3 }, - }) - return typedClient(client, ...config.schemas) -} +export const EncryptionV3 = Encryption // Single import surface: re-export the v3 `types` namespace + table API + type // helpers so `@cipherstash/stack/v3` provides everything needed to author and diff --git a/packages/stack/src/schema/index.ts b/packages/stack/src/schema/index.ts index 27fa88d47..a53ece0a2 100644 --- a/packages/stack/src/schema/index.ts +++ b/packages/stack/src/schema/index.ts @@ -219,6 +219,9 @@ export type EncryptConfig = z.infer * Builder for a nested encrypted field (encrypted but not searchable). * Create with {@link encryptedField}. Use inside nested objects in {@link encryptedTable}; * supports `.dataType()` for plaintext type. No index methods (equality, orderAndRange, etc.). + * + * @deprecated EQL v2 authoring. The client authors EQL v3; this class backs the + * v2 `encryptedField` builder, retained only for reading/migrating legacy v2 data. */ export class EncryptedField { private valueName: string @@ -263,6 +266,12 @@ export class EncryptedField { } } +/** + * Builder for an EQL v2 encrypted column. Create with {@link encryptedColumn}. + * + * @deprecated EQL v2 authoring. The client authors EQL v3; this class backs the + * v2 `encryptedColumn` builder, retained only for reading/migrating legacy v2 data. + */ export class EncryptedColumn { private columnName: string private castAsValue: CastAs @@ -602,6 +611,11 @@ export type InferEncrypted> = * so you can reference columns as `users.email` when calling `encrypt`, `decrypt`, * and `encryptQuery`. * + * @deprecated EQL v2 authoring. The client authors EQL v3 — build tables with + * `encryptedTable` + the `types.*` factories from `@cipherstash/stack/v3`. This + * v2 builder remains only for reading/migrating legacy v2 data and will be + * removed once the v2 adapters are gone. + * * @param tableName - The name of the database table this schema represents. * @param columns - An object whose keys are logical column names and values are * {@link EncryptedColumn} from {@link encryptedColumn}, or nested objects whose @@ -649,6 +663,11 @@ export function encryptedTable( * `.searchableJson()`) and/or `.dataType()` to configure searchable encryption * and the plaintext data type. * + * @deprecated EQL v2 authoring. The client authors EQL v3 — define columns with + * the `types.*` concrete-domain factories from `@cipherstash/stack/v3`. This v2 + * builder remains only for reading/migrating legacy v2 data and will be removed + * once the v2 adapters are gone. + * * @param columnName - The name of the database column to encrypt. * @returns A new `EncryptedColumn` builder. * @@ -672,6 +691,11 @@ export function encryptedColumn(columnName: string) { * for nested fields that are encrypted but not searchable (no indexes). Use `.dataType()` * to specify the plaintext type. * + * @deprecated EQL v2 authoring. The client authors EQL v3 — model nested fields + * with a dotted v3 column path (`'profile.ssn': types.TextEq(...)`) from + * `@cipherstash/stack/v3`. This v2 builder remains only for reading/migrating + * legacy v2 data and will be removed once the v2 adapters are gone. + * * @param valueName - The name of the value field. * @returns A new `EncryptedField` builder. * diff --git a/packages/stack/src/types.ts b/packages/stack/src/types.ts index c516d848e..6eee471b2 100644 --- a/packages/stack/src/types.ts +++ b/packages/stack/src/types.ts @@ -171,6 +171,12 @@ export type ClientConfig = { strategy?: AuthStrategy /** + * @deprecated The client authors EQL v3 — an all-v3 schema set forces `3` + * automatically and yields the typed client. This field remains only to read + * or write legacy EQL v2 during migration (e.g. `eqlVersion: 2` with a v2 + * schema set), and will be removed once the v2 adapters are gone. New code + * should not set it. + * * The EQL wire version the client emits — one FFI client always emits * exactly one wire format. * diff --git a/skills/stash-dynamodb/SKILL.md b/skills/stash-dynamodb/SKILL.md index cf3d44d47..2369d75b6 100644 --- a/skills/stash-dynamodb/SKILL.md +++ b/skills/stash-dynamodb/SKILL.md @@ -44,18 +44,20 @@ Non-encrypted attributes pass through unchanged. On decryption, the `__source` a ## Choosing a Schema Version -`encryptedDynamoDB` accepts both EQL v3 and EQL v2 tables. **Use EQL v3 for new projects.** +**Encrypt/write is EQL v3 only.** `encryptModel` / `bulkEncryptModels` accept only EQL v3 tables (`types.*` domains). **Decrypt still reads existing EQL v2 items** — `decryptModel` / `bulkDecryptModels` continue to accept an EQL v2 table so previously stored data stays readable. Author all new tables with EQL v3; keep a v2 table around only to read old data. -| | EQL v3 (recommended) | EQL v2 (existing deployments) | +| | EQL v3 (author + read) | EQL v2 (read existing data only) | |---|---|---| | Import | `@cipherstash/stack/v3` | `@cipherstash/stack` + `@cipherstash/stack/schema` | | Schema | `encryptedTable` + `types.*` | `encryptedTable` + `encryptedColumn` | -| Client | `EncryptionV3({ schemas })` | `Encryption({ schemas })` | +| Client | `Encryption({ schemas })` (or the deprecated `EncryptionV3`) | `Encryption({ schemas })` | +| encrypt | ✅ | ❌ removed — write is v3 only | +| decrypt | ✅ | ✅ reads stored v2 items | | Nested fields | Flat dotted path (`"profile.ssn"`) | Nested group + `encryptedField` | -There is no infrastructure migration between them — DynamoDB has no EQL extension to install and no schema to alter — but there is no automatic *data* migration path either. The two write **different wire formats**, so items written under one version cannot be decrypted by the other. Pick one per table and stay on it; to switch an existing table you must re-encrypt every item. +There is no infrastructure migration between the versions — DynamoDB has no EQL extension to install and no schema to alter — and there is no automatic *data* migration either. The two use **different wire formats**; a stored v2 item still decrypts through a v2 table, but new writes are v3. To fully move a table to v3, re-encrypt every item with a v3 schema. -The client must be built for the table. Build the client with the same v3 table you hand to `encryptedDynamoDB` — `EncryptionV3({ schemas: [users] })` (or `Encryption({ schemas: [users], config: { eqlVersion: 3 } })`). Passing a v3 table to a client that never registered it (a client built for a different schema set) throws a clear error naming the table on the first operation, instead of failing later with an opaque FFI deserialization error. +The client must be built for the table. Build the client with the same v3 table you hand to `encryptedDynamoDB` — `Encryption({ schemas: [users] })` returns the typed v3 client for a concrete v3 schema set. Passing a v3 table to a client that never registered it (a client built for a different schema set) throws a clear error naming the table on the first operation, instead of failing later with an opaque FFI deserialization error. **Nested attributes work in both versions**, with different authoring syntax. @@ -156,29 +158,34 @@ Which domains give you a `__hmac` attribute you can query on: ```typescript import { DynamoDBClient } from "@aws-sdk/client-dynamodb" import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb" -import { EncryptionV3 } from "@cipherstash/stack/v3" +import { Encryption } from "@cipherstash/stack" import { encryptedDynamoDB } from "@cipherstash/stack/dynamodb" const dynamoClient = new DynamoDBClient({ region: "us-east-1" }) const docClient = DynamoDBDocumentClient.from(dynamoClient) -const encryptionClient = await EncryptionV3({ schemas: [users] }) +const encryptionClient = await Encryption({ schemas: [users] }) const dynamo = encryptedDynamoDB({ encryptionClient }) ``` -> **Audit metadata on decrypt:** the client from `EncryptionV3` has no audit -> surface on its decrypt methods, so `.audit()` on `decryptModel` / -> `bulkDecryptModels` resolves normally but the metadata is not recorded. If you -> need audited decrypts, build the client with `Encryption({ schemas: [users], -> config: { eqlVersion: 3 } })` instead — same v3 wire format, chainable decrypt. +> **Audit metadata on decrypt works.** `decryptModel` / `bulkDecryptModels` are +> audit-chainable — `dynamo.decryptModel(item, table).audit({ metadata })` forwards +> the metadata to ZeroKMS, whether the client came from `Encryption` or the +> deprecated `EncryptionV3` alias. (`EncryptionV3` is now a deprecated, type-identical +> alias of `Encryption`; prefer `Encryption`.) + +### EQL v2 Schema (reading existing deployments) -### EQL v2 Schema (existing deployments) +A v2 table is **read-only** through this adapter now: pass it to `decryptModel` / +`bulkDecryptModels` to read items written before the v3 cutover. `encryptModel` / +`bulkEncryptModels` no longer accept a v2 table — author new writes with an EQL v3 +table. ```typescript import { encryptedTable, encryptedColumn, encryptedField } from "@cipherstash/stack/schema" import { Encryption } from "@cipherstash/stack" -const users = encryptedTable("users", { +const usersV2 = encryptedTable("users", { email: encryptedColumn("email").equality(), // searchable via HMAC name: encryptedColumn("name"), // encrypt-only, no search metadata: encryptedColumn("metadata").dataType("json"), @@ -187,8 +194,11 @@ const users = encryptedTable("users", { }, }) -const encryptionClient = await Encryption({ schemas: [users] }) +const encryptionClient = await Encryption({ schemas: [usersV2] }) const dynamo = encryptedDynamoDB({ encryptionClient }) + +// Read existing v2 items — decrypt still accepts the v2 table. +const decrypted = await dynamo.decryptModel(storedV2Item, usersV2) ``` > **Note:** `encryptedColumn` also supports `.orderAndRange()`, `.freeTextSearch()`, and `.searchableJson()` index methods, but only `.equality()` produces HMAC values usable for DynamoDB key condition queries. @@ -456,7 +466,7 @@ if (result.failure) { import { encryptedDynamoDB } from "@cipherstash/stack/dynamodb" const dynamo = encryptedDynamoDB({ - // From EncryptionV3(...) or Encryption(...) + // From Encryption(...) (or the deprecated EncryptionV3(...) alias) encryptionClient, options: { // optional logger: { error: (message, error) => void }, @@ -467,9 +477,7 @@ const dynamo = encryptedDynamoDB({ ### Instance Methods -`table` is either an EQL v3 table (`types.*` domains) or an EQL v2 one. - -Each method is overloaded on the table's EQL version. +**Encrypt accepts an EQL v3 table only. Decrypt accepts an EQL v3 OR an EQL v2 table** (so stored v2 items stay readable). The decrypt methods are overloaded on the table's EQL version. **EQL v3** — the item is checked against the table's column domains, and the result is typed as the attribute map that is actually stored: a declared column `email` becomes `email__source` (plus `email__hmac` if its domain mints one), NOT `email`. @@ -482,16 +490,14 @@ Each method is overloaded on the table's EQL version. Let `T` be inferred from the argument; do not pass explicit type arguments on the v3 path. -**EQL v2** — unchanged. `T` is the model you pass in, so the result type does NOT reflect the `__source` / `__hmac` split; declare a type of your own to read those attributes. +**EQL v2 — decrypt (read) only.** The v2 write overloads were removed; `encryptModel` / `bulkEncryptModels` no longer accept a v2 table. `decryptModel` / `bulkDecryptModels` still do, so existing v2 items decrypt. `T` is the model shape you expect back; declare a type of your own to read the stored `__source` / `__hmac` attributes. | Method | Signature | Resolves to | |---|---|---| -| `encryptModel` | `(item: T, v2Table)` | `T` | -| `bulkEncryptModels` | `(items: T[], v2Table)` | `T[]` | | `decryptModel` | `(item: Record, v2Table)` | `T` | | `bulkDecryptModels` | `(items: Record[], v2Table)` | `T[]` | -All operations are thenable (awaitable) and support `.audit({ metadata })` chaining. See the note in Setup about decrypt-side audit metadata and the `EncryptionV3` client. +All operations are thenable (awaitable) and support `.audit({ metadata })` chaining — including the decrypt methods, whose metadata now forwards to ZeroKMS regardless of client shape (see the Setup note). Types exported from `@cipherstash/stack/dynamodb`: `EncryptedDynamoDBInstance`, `EncryptedDynamoDBConfig`, `EncryptedDynamoDBError`, `AnyEncryptedTable`, `DynamoDBEncryptionClient`, `EncryptedAttributes`, `DecryptedAttributes`, `AuditConfig`. @@ -529,7 +535,8 @@ const v2Hmac = v2Result.data.hm ```typescript import { DynamoDBClient } from "@aws-sdk/client-dynamodb" import { DynamoDBDocumentClient, PutCommand, GetCommand, QueryCommand } from "@aws-sdk/lib-dynamodb" -import { EncryptionV3, encryptedTable, types } from "@cipherstash/stack/v3" +import { Encryption } from "@cipherstash/stack" +import { encryptedTable, types } from "@cipherstash/stack/v3" import { encryptedDynamoDB } from "@cipherstash/stack/dynamodb" // Schema @@ -541,7 +548,7 @@ const users = encryptedTable("users", { // Clients const dynamoClient = new DynamoDBClient({ region: "us-east-1" }) const docClient = DynamoDBDocumentClient.from(dynamoClient) -const encryptionClient = await EncryptionV3({ schemas: [users] }) +const encryptionClient = await Encryption({ schemas: [users] }) const dynamo = encryptedDynamoDB({ encryptionClient }) // Write diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 6c013d165..5b440c8f0 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -1,13 +1,13 @@ --- name: stash-encryption -description: Implement field-level encryption with @cipherstash/stack using the EQL v3 typed schema. Covers the types.* column catalog (concrete Postgres domains with fixed query capabilities), the strongly-typed EncryptionV3 client, encrypt/decrypt and model operations, searchable encryption (equality, free-text, range), encrypted JSON (containment and JSONPath selectors), bulk operations, identity-aware encryption with lock contexts, multi-tenant keysets, and the rollout/cutover lifecycle. Use when adding encryption to a project, defining encrypted schemas, or working with the CipherStash Encryption API. +description: Implement field-level encryption with @cipherstash/stack using the EQL v3 typed schema. Covers the types.* column catalog (concrete Postgres domains with fixed query capabilities), the strongly-typed Encryption client (with EncryptionV3 as a deprecated alias), encrypt/decrypt and model operations, searchable encryption (equality, free-text, range), encrypted JSON (containment and JSONPath selectors), bulk operations, identity-aware encryption with lock contexts, multi-tenant keysets, and the rollout/cutover lifecycle. Use when adding encryption to a project, defining encrypted schemas, or working with the CipherStash Encryption API. --- # CipherStash Stack - Encryption Comprehensive guide for implementing field-level encryption with `@cipherstash/stack`. Every value is encrypted with its own unique key via ZeroKMS (backed by AWS KMS). Encryption happens client-side before data leaves the application. -Encrypted columns are **EQL v3 concrete Postgres domains** (`public.eql_v3_text_search`, `public.eql_v3_integer_ord`, ...): each column's query capabilities are fixed by the domain type you pick in the schema, and the `EncryptionV3` client derives precise TypeScript types from that schema — wrong-typed plaintext is a compile error, not a runtime surprise. +Encrypted columns are **EQL v3 concrete Postgres domains** (`public.eql_v3_text_search`, `public.eql_v3_integer_ord`, ...): each column's query capabilities are fixed by the domain type you pick in the schema, and the `Encryption` client (typed for an all-v3 schema set) derives precise TypeScript types from that schema — wrong-typed plaintext is a compile error, not a runtime surprise. > An older schema surface (EQL v2, with chainable capability builders) still exists for existing deployments — see "Legacy: EQL v2" at the end. Everything else in this skill is the v3 surface. New projects should use v3. @@ -25,13 +25,14 @@ Encrypted columns are **EQL v3 concrete Postgres domains** (`public.eql_v3_text_ ## Quick Start ```typescript -import { EncryptionV3, encryptedTable, types } from "@cipherstash/stack/v3" +import { Encryption } from "@cipherstash/stack" +import { encryptedTable, types } from "@cipherstash/stack/v3" const users = encryptedTable("users", { email: types.TextSearch("email"), }) -const client = await EncryptionV3({ schemas: [users] }) +const client = await Encryption({ schemas: [users] }) const encrypted = await client.encrypt("secret@example.com", { table: users, @@ -145,7 +146,7 @@ profile. ### Programmatic Config ```typescript -const client = await EncryptionV3({ +const client = await Encryption({ schemas: [users], config: { workspaceCrn: "crn:ap-southeast-2.aws:your-workspace-id", @@ -181,16 +182,16 @@ The SDK never logs plaintext data. | Import Path | Provides | |---|---| -| `@cipherstash/stack/v3` | `EncryptionV3` factory, `typedClient`, `TypedEncryptionClient` — plus re-exports of everything in `@cipherstash/stack/eql/v3`. The one-stop import for v3. | +| `@cipherstash/stack/v3` | `EncryptionV3` (a deprecated alias of `Encryption`), `typedClient`, `TypedEncryptionClient` — plus re-exports of everything in `@cipherstash/stack/eql/v3`. The one-stop import for v3 schema authoring. | | `@cipherstash/stack/eql/v3` | `encryptedTable`, the `types` namespace, `buildEncryptConfig`, inference types (`InferPlaintext`, `InferEncrypted`, `V3ModelInput`, ...) | -| `@cipherstash/stack` | `OidcFederationStrategy`, `AccessKeyStrategy`, the untyped `Encryption` function (also accepts v3 schemas), legacy v2 re-exports | +| `@cipherstash/stack` | `OidcFederationStrategy`, `AccessKeyStrategy`, the `Encryption` function (typed for an all-v3 schema set; nominal for v2/loose schemas), legacy v2 re-exports | | `@cipherstash/stack/identity` | `LockContext` class and identity types | | `@cipherstash/stack/errors` | `EncryptionErrorTypes`, `StackError`, error subtypes, `getErrorMessage` | | `@cipherstash/stack/types` | All TypeScript types | | `@cipherstash/stack-drizzle/v3` | Drizzle ORM integration for EQL v3 schemas (see the `stash-drizzle` skill) | | `@cipherstash/stack-supabase` | `encryptedSupabaseV3` wrapper for Supabase (see the `stash-supabase` skill) | | `@cipherstash/stack/wasm-inline` | The **edge** entry — Deno, Bun, Cloudflare Workers, Supabase Edge Functions. Its own `Encryption` factory plus the v3 authoring surface, `EncryptionErrorTypes`, and the WASM build of protect-ffi inlined into the bundle. No native binding, so no bundler externalisation needed. | -| `@cipherstash/stack/dynamodb` | `encryptedDynamoDB` — **still requires the legacy v2 schema surface**; see "Legacy: EQL v2" below | +| `@cipherstash/stack/dynamodb` | `encryptedDynamoDB` — encrypt/write is **EQL v3 only** (`types.*`); decrypt still reads existing v2 items. See the `stash-dynamodb` skill | | `@cipherstash/stack/schema`, `@cipherstash/stack/client`, `@cipherstash/stack/encryption` | Legacy v2 schema builders and client surface — see "Legacy: EQL v2" below | ## Schema Definition @@ -289,12 +290,13 @@ CREATE TABLE users ( ); ``` -## Client Initialization: `EncryptionV3` +## Client Initialization: `Encryption` -`EncryptionV3` from `@cipherstash/stack/v3` returns a `TypedEncryptionClient` whose method signatures are derived from your schemas — wrong-typed plaintext is rejected at compile time, and query methods only accept queryable columns with `queryType` constrained to the column's capabilities: +`Encryption` from `@cipherstash/stack` is overloaded: hand it an array of concrete EQL v3 tables and it returns a `TypedEncryptionClient` whose method signatures are derived from your schemas — wrong-typed plaintext is rejected at compile time, and query methods only accept queryable columns with `queryType` constrained to the column's capabilities. `encryptedTable` / `types` are imported from `@cipherstash/stack/v3`: ```typescript -import { EncryptionV3, encryptedTable, types } from "@cipherstash/stack/v3" +import { Encryption } from "@cipherstash/stack" +import { encryptedTable, types } from "@cipherstash/stack/v3" const users = encryptedTable("users", { email: types.TextSearch("email"), @@ -302,18 +304,19 @@ const users = encryptedTable("users", { balance: types.BigintEq("balance"), }) -const client = await EncryptionV3({ schemas: [users] }) +const client = await Encryption({ schemas: [users] }) ``` -- The wire format is pinned to EQL v3 (`eqlVersion: 3`); you don't set it yourself. -- `EncryptionV3()` throws on init error (bad credentials, missing config, invalid keyset UUID). At least one schema is required. -- `typedClient(client, ...schemas)` (also exported from `@cipherstash/stack/v3`) wraps an already-built untyped `EncryptionClient` in the typed surface. -- The untyped `Encryption({ schemas })` from `@cipherstash/stack` also accepts v3 tables (it auto-detects them and sets the v3 wire format), but you lose the per-column typing — prefer `EncryptionV3`. **v2 and v3 tables cannot be mixed in one client** — a mixed schema set throws at init. Create separate clients if you need both. +- The wire format is auto-detected as EQL v3 for an all-v3 schema set; you don't set it yourself. +- `Encryption()` throws on init error (bad credentials, missing config, invalid keyset UUID). At least one schema is required. +- `EncryptionV3` (from `@cipherstash/stack/v3`) is a **deprecated, type-identical alias** of `Encryption`, kept for backwards compatibility. New code should use `Encryption`. +- `typedClient(client, ...schemas)` (exported from `@cipherstash/stack/v3`) wraps an already-built untyped `EncryptionClient` in the typed surface, if you built one via a lower-level path. +- **v2 and v3 tables cannot be mixed in one client** — a mixed schema set throws at init. Create separate clients if you need both. ```typescript // Error handling try { - const client = await EncryptionV3({ schemas: [users] }) + const client = await Encryption({ schemas: [users] }) } catch (error) { console.error("Init failed:", error.message) } @@ -426,7 +429,7 @@ for (const item of decrypted.data) { **On the WASM entry (`@cipherstash/stack/wasm-inline`), the batch shape differs** — do not copy the shape above onto the edge. The `{ data } | { failure }` Result is the same, but there are no `{ id, plaintext }` envelopes: each entry carries its own table and column, and the payload is a plain index-aligned array. > [!IMPORTANT] -> The `client` below is a **different client** from the one used everywhere else in this skill. The edge entry has its own `Encryption` factory — the native `EncryptionV3` client's `bulkEncrypt` takes `(plaintexts, { table, column })` and will fail at runtime if given the per-item shape below. Construct the WASM client explicitly: +> The `client` below is a **different client** from the one used everywhere else in this skill. The edge entry has its own `Encryption` factory — the native `Encryption` client's `bulkEncrypt` takes `(plaintexts, { table, column })` and will fail at runtime if given the per-item shape below. Construct the WASM client explicitly: ```typescript // Deno / Workers / Supabase Edge Functions — note the import path @@ -612,7 +615,8 @@ The client authenticates to ZeroKMS through `config.authStrategy`. Leave it unse ```typescript import { OidcFederationStrategy } from "@cipherstash/stack" -import { EncryptionV3, encryptedTable, types } from "@cipherstash/stack/v3" +import { Encryption } from "@cipherstash/stack" +import { encryptedTable, types } from "@cipherstash/stack/v3" const users = encryptedTable("users", { email: types.TextSearch("email") }) @@ -626,7 +630,7 @@ if (strategy.failure) { throw new Error(`[auth] ${strategy.failure.type}: ${strategy.failure.error.message}`) } -const client = await EncryptionV3({ +const client = await Encryption({ schemas: [users], config: { authStrategy: strategy.data }, }) @@ -691,13 +695,13 @@ Isolate encryption keys per tenant: ```typescript // By name -const client = await EncryptionV3({ +const client = await Encryption({ schemas: [users], config: { keyset: { name: "Company A" } }, }) // By UUID -const client = await EncryptionV3({ +const client = await Encryption({ schemas: [users], config: { keyset: { id: "123e4567-e89b-12d3-a456-426614174000" } }, }) @@ -975,7 +979,7 @@ await runBackfill({ }) ``` -Useful when the backfill needs to run in a worker, on a schedule, or alongside an existing job runner. `runBackfill` is version-agnostic — for an EQL v3 column pass an `EncryptionV3` client (from `@cipherstash/stack/v3`) and it writes v3 envelopes straight into the concrete `eql_v3_*` domain column. +Useful when the backfill needs to run in a worker, on a schedule, or alongside an existing job runner. `runBackfill` is version-agnostic — for an EQL v3 column pass an `Encryption` client built from a v3 schema set and it writes v3 envelopes straight into the concrete `eql_v3_*` domain column. ### Invariants the rollout preserves @@ -992,7 +996,7 @@ Useful when the backfill needs to run in a worker, on a schedule, or alongside a | Drizzle ORM | `@cipherstash/stack-drizzle/v3` — v3 column factories (each `types.*` factory emits its domain as the column's SQL type for `drizzle-kit generate`), schema extraction, auto-encrypting operators (`ops.eq`, `ops.matches`, `ops.contains`, `ops.selector`, `ops.asc`, ...) | `stash-drizzle` | | Supabase | `encryptedSupabaseV3` from `@cipherstash/stack-supabase` — schema-aware query builder (`eq`, `matches`, `contains`, `selectorEq`/`selectorNe`, ...) that works through PostgREST, including as `anon` | `stash-supabase` | | Prisma | `@cipherstash/prisma-next` — searchable field-level encryption for Postgres | — | -| DynamoDB | `encryptedDynamoDB` from `@cipherstash/stack/dynamodb` — **v2 schema surface only**, see the exception note under "Legacy: EQL v2" | `stash-dynamodb` | +| DynamoDB | `encryptedDynamoDB` from `@cipherstash/stack/dynamodb` — encrypt is **EQL v3 only**; decrypt still reads existing v2 items | `stash-dynamodb` | ## Complete API Reference @@ -1040,4 +1044,4 @@ const client = await Encryption({ schemas: [users] }) The scalar v2 API — `Encryption` plus the `@cipherstash/stack/schema` builders, the v2 Drizzle/Supabase integrations (`@cipherstash/stack-drizzle`, `encryptedSupabase`), and `stash eql install --eql-version 2` — still exists for existing deployments. Legacy v2 `searchableJson()` is the exception: protect-ffi 0.30 cannot emit the removed selector envelope, so migrate those columns to v3 `types.Json`. Full v2 documentation lives at [cipherstash.com/docs](https://cipherstash.com/docs). Remember: v2 and v3 tables cannot be mixed in one client. (If you are migrating code from the old `@cipherstash/protect` package, its `protect`/`csTable`/`csColumn` names map onto this v2 surface.) -> **Exception — DynamoDB.** The DynamoDB integration (`encryptedDynamoDB` from `@cipherstash/stack/dynamodb`) **still requires the v2 schema surface** — `encryptedTable`/`encryptedColumn`/`encryptedField` from `@cipherstash/stack/schema`. Do not use v3 `types.*` schemas with it. See the `stash-dynamodb` skill; v3 support is tracked in [cipherstash/stack#657](https://github.com/cipherstash/stack/issues/657). +> **DynamoDB.** The DynamoDB integration (`encryptedDynamoDB` from `@cipherstash/stack/dynamodb`) now **encrypts EQL v3 only** — author tables with `types.*` from `@cipherstash/stack/eql/v3`. Its decrypt methods still accept a v2 table so previously stored v2 items remain readable. See the `stash-dynamodb` skill. From 04f19427be64dbe72862d54afa7310e9da361595 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 23:44:48 +1000 Subject: [PATCH 2/4] fix(stack): typed client tolerates nominal-arity decrypt calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verification of the EncryptionV3->Encryption collapse surfaced a regression: because Encryption now returns the TYPED client for a v3 schema set, stack-supabase's encryptedSupabaseV3 (which casts to the nominal overload and calls decryptModel(row)/bulkDecryptModels(rows) one-arg) hit the typed methods with no table argument and threw on undefined.tableName. The sibling build passed because it is a type/runtime mismatch with no v3 runtime coverage. Fix: the typed client's decryptModel/bulkDecryptModels now tolerate a missing table, degrading to nominal behaviour (decrypt without date reconstruction) instead of dereferencing undefined — restoring exactly what stack-supabase received before the collapse. table stays required for genuinely-typed callers. - Add regression tests: typed client one-arg decryptModel/bulkDecryptModels decrypt without throwing (no reconstruction). - Correct now-stale customer-visible source docs flagged in review: the resolveDecryptResult docblock, the encryptedDynamoDB version-mismatch error string and @example, and the EncryptedDynamoDBConfig JSDoc (all pointed at EncryptionV3 / needless eqlVersion:3). Update construct-guard.test.ts to the new error wording. Follows 686004f8. --- .../dynamodb/construct-guard.test.ts | 4 +- .../stack/__tests__/typed-client-v3.test.ts | 48 +++++++++++++++++++ packages/stack/src/dynamodb/helpers.ts | 10 ++-- packages/stack/src/dynamodb/index.ts | 9 ++-- packages/stack/src/dynamodb/types.ts | 7 ++- packages/stack/src/encryption/v3.ts | 41 ++++++++++++---- 6 files changed, 97 insertions(+), 22 deletions(-) diff --git a/packages/stack/__tests__/dynamodb/construct-guard.test.ts b/packages/stack/__tests__/dynamodb/construct-guard.test.ts index 88c95e997..9ab09ea72 100644 --- a/packages/stack/__tests__/dynamodb/construct-guard.test.ts +++ b/packages/stack/__tests__/dynamodb/construct-guard.test.ts @@ -68,7 +68,9 @@ describe('encryptedDynamoDB client/table version guard', () => { message = (e as Error).message } expect(message).toContain('users_v3') - expect(message).toMatch(/EncryptionV3|eqlVersion/) + // Post-collapse the fix guidance points at `Encryption` (which auto-selects + // the v3 wire format for a v3 schema set), not `EncryptionV3`/`eqlVersion`. + expect(message).toMatch(/build it with Encryption\(/) }) it('guards every operation method, not just encryptModel', () => { diff --git a/packages/stack/__tests__/typed-client-v3.test.ts b/packages/stack/__tests__/typed-client-v3.test.ts index 457fc8378..c506d787f 100644 --- a/packages/stack/__tests__/typed-client-v3.test.ts +++ b/packages/stack/__tests__/typed-client-v3.test.ts @@ -140,4 +140,52 @@ describe('typedClient — decrypt reconstruction', () => { const data = result.data as Record expect(data.when).toBeInstanceOf(Date) }) + + // `Encryption` now returns THIS typed client for a v3 schema set, so a consumer + // typed against the nominal overload (e.g. stack-supabase's query builder, + // which casts to it and calls the one-arg `decryptModel(row)` / + // `bulkDecryptModels(rows)`) reaches the typed methods with NO table argument. + // They must decrypt without throwing — degrading to nominal behaviour (no date + // reconstruction) — not dereference `undefined.tableName`. + it('tolerates a one-arg (nominal-style) decryptModel call with no table', async () => { + const client = typedClient( + fakeClient({ when: '2020-01-02T03:04:05.000Z', note: 'hi' }), + table, + ) + // The typed signature forbids the one-arg form; a nominal-typed caller does + // it at runtime. Exercise that runtime path. + // biome-ignore lint/suspicious/noExplicitAny: exercising the nominal-arity runtime path + const decryptOneArg = client.decryptModel as any + + const result = await decryptOneArg({ + when: '2020-01-02T03:04:05.000Z', + note: 'hi', + }) + expect(result.failure).toBeFalsy() + if (result.failure) return + + const data = result.data as Record + // No table → no reconstruction: `when` stays the raw string, exactly as the + // nominal client would return it. + expect(data.when).toBe('2020-01-02T03:04:05.000Z') + expect(data.note).toBe('hi') + }) + + it('tolerates a one-arg (nominal-style) bulkDecryptModels call with no table', async () => { + const client = typedClient( + fakeClient({ when: '2021-06-01T00:00:00.000Z', note: 'x' }), + table, + ) + // biome-ignore lint/suspicious/noExplicitAny: exercising the nominal-arity runtime path + const bulkOneArg = client.bulkDecryptModels as any + + const result = await bulkOneArg([{ when: '2021-06-01T00:00:00.000Z' }]) + expect(result.failure).toBeFalsy() + if (result.failure) return + + const rows = result.data as Array> + expect(rows).toHaveLength(1) + // No table → no reconstruction: raw string, not a Date. + expect(rows[0].when).toBe('2021-06-01T00:00:00.000Z') + }) }) diff --git a/packages/stack/src/dynamodb/helpers.ts b/packages/stack/src/dynamodb/helpers.ts index 68966d409..67be28a68 100644 --- a/packages/stack/src/dynamodb/helpers.ts +++ b/packages/stack/src/dynamodb/helpers.ts @@ -86,11 +86,11 @@ export function handleError( /** * Resolve a decrypt call against either client shape. * - * The nominal `EncryptionClient` returns a chainable operation that carries - * `.audit()`; the `TypedEncryptionClient` from `EncryptionV3` returns a plain - * `Promise>`. Chain the audit metadata when the client can carry it, - * otherwise await the promise directly — a typed client has no audit surface - * on decrypt, so the metadata has nowhere to go. + * Both the nominal `EncryptionClient` and the typed client return a chainable + * operation carrying `.audit()` on decrypt (the typed client's is a + * `MappedDecryptOperation`). Chain the audit metadata onto it; the branch that + * awaits a bare promise remains only for a non-conforming custom client that + * exposes no `.audit()`. Audit metadata is forwarded regardless of client shape. */ export async function resolveDecryptResult( operation: unknown, diff --git a/packages/stack/src/dynamodb/index.ts b/packages/stack/src/dynamodb/index.ts index 9e2c60aad..a950c8f7c 100644 --- a/packages/stack/src/dynamodb/index.ts +++ b/packages/stack/src/dynamodb/index.ts @@ -57,7 +57,7 @@ function assertClientTableVersionMatch( if (table.tableName in config.tables) return throw new Error( - `encryptedDynamoDB: EQL version mismatch — the EQL v3 table "${table.tableName}" is not registered with this encryption client, so the client is not in EQL v3 mode for it. A v3 table requires a v3-mode client: build it with EncryptionV3({ schemas: [
] }) (or Encryption({ schemas: [
], config: { eqlVersion: 3 } })) and pass that client to encryptedDynamoDB. Otherwise encrypt/decrypt fails later inside the FFI with an opaque deserialization error.`, + `encryptedDynamoDB: EQL version mismatch — the EQL v3 table "${table.tableName}" is not registered with this encryption client, so the client is not in EQL v3 mode for it. A v3 table requires a v3-mode client: build it with Encryption({ schemas: [
] }) and pass that client to encryptedDynamoDB. Otherwise encrypt/decrypt fails later inside the FFI with an opaque deserialization error.`, ) } @@ -85,7 +85,8 @@ function assertClientTableVersionMatch( * * @example EQL v3 * ```typescript - * import { EncryptionV3, encryptedTable, types } from "@cipherstash/stack/v3" + * import { Encryption } from "@cipherstash/stack" + * import { encryptedTable, types } from "@cipherstash/stack/v3" * import { encryptedDynamoDB } from "@cipherstash/stack/dynamodb" * * const users = encryptedTable("users", { @@ -93,13 +94,13 @@ function assertClientTableVersionMatch( * name: types.Text("name"), // storage only * }) * - * const client = await EncryptionV3({ schemas: [users] }) + * const client = await Encryption({ schemas: [users] }) * const dynamo = encryptedDynamoDB({ encryptionClient: client }) * * const encrypted = await dynamo.encryptModel({ email: "a@b.com" }, users) * ``` * - * @example EQL v2 (existing deployments) + * @example EQL v2 (reading existing deployments — decrypt only) * ```typescript * import { Encryption } from "@cipherstash/stack" * import { encryptedDynamoDB } from "@cipherstash/stack/dynamodb" diff --git a/packages/stack/src/dynamodb/types.ts b/packages/stack/src/dynamodb/types.ts index d661928a3..9dfebb8cb 100644 --- a/packages/stack/src/dynamodb/types.ts +++ b/packages/stack/src/dynamodb/types.ts @@ -94,10 +94,9 @@ export type CallableEncryptionClient = { export interface EncryptedDynamoDBConfig { /** - * Either the nominal client from `Encryption(...)` / `Encryption({ schemas, - * config: { eqlVersion: 3 } })`, or the typed client from `EncryptionV3(...)`. - * For EQL v3 tables the client must be in v3 mode — `EncryptionV3` forces - * this; with `Encryption` you must pass `config: { eqlVersion: 3 }` yourself. + * The client from `Encryption(...)` (or the deprecated `EncryptionV3(...)` + * alias). For an EQL v3 schema set `Encryption` auto-selects the v3 wire format + * and returns the typed client — no `config: { eqlVersion: 3 }` needed. */ encryptionClient: EncryptionClient | DynamoDBEncryptionClient options?: { diff --git a/packages/stack/src/encryption/v3.ts b/packages/stack/src/encryption/v3.ts index 34c4602c8..e36c90731 100644 --- a/packages/stack/src/encryption/v3.ts +++ b/packages/stack/src/encryption/v3.ts @@ -218,6 +218,16 @@ export function 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) => row + const passthroughRows = (rows: Array>) => 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 @@ -258,9 +268,14 @@ export function typedClient( client.bulkEncryptModels(input as never, table as never) as never, decrypt: (encrypted) => client.decrypt(encrypted), decryptModel: (input, table, lockContext) => { - // `reconstruct` is undefined for a table this client was not initialized - // with; the mapped op then resolves to `unknownTableFailure` on execute. - const reconstruct = reconstructors.get(table.tableName) + // `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 maybeTable = table as AnyV3Table | undefined + const reconstruct = maybeTable + ? reconstructors.get(maybeTable.tableName) + : passthroughRow const op = client.decryptModel(input as never) const base = lockContext ? op.withLockContext(lockContext) : op return new MappedDecryptOperation( @@ -270,13 +285,23 @@ export function typedClient( ) as never }, bulkDecryptModels: (input, table, lockContext) => { - const reconstruct = reconstructors.get(table.tableName) + const maybeTable = table as AnyV3Table | undefined const op = client.bulkDecryptModels(input as never) const base = lockContext ? op.withLockContext(lockContext) : op - // The underlying op resolves to an array of rows; reconstruct each. - const mapRows = reconstruct - ? (rows: Array>) => rows.map(reconstruct) - : undefined + // No table → pass rows through (nominal behaviour). Registered table → + // reconstruct each row. Unregistered table → `undefined` map → + // `unknownTableFailure` on execute. + let mapRows: + | (( + rows: Array>, + ) => Array>) + | undefined + if (!maybeTable) { + mapRows = passthroughRows + } else { + const reconstruct = reconstructors.get(maybeTable.tableName) + mapRows = reconstruct ? (rows) => rows.map(reconstruct) : undefined + } return new MappedDecryptOperation( base, mapRows, From d6d8978659af922ff6d21dfd2b258e926ed3e167 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 23 Jul 2026 11:23:20 +1000 Subject: [PATCH 3/4] chore(stack): address review nits - MappedDecryptOperation.execute returns a fresh Result on the unknown-table path, so ops can't alias/mutate a shared failure object. - Document the AnyV3Table cast in Encryption with a biome-ignore explaining the runtime isV3Only guard the compiler can't see. - Restore CS_WORKSPACE_CRN in decrypt-audit-forwarding.test.ts afterEach so it doesn't leak env state across suites. Verified the v2-read acceptance tests (integration/shared/v2-decrypt-compat) match the CI CS_IT_SUITE glob, so #1a/#1b run under test:integration. --- .../__tests__/decrypt-audit-forwarding.test.ts | 14 +++++++++++++- packages/stack/src/encryption/index.ts | 10 +++++++--- .../src/encryption/operations/mapped-decrypt.ts | 3 ++- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/stack/__tests__/decrypt-audit-forwarding.test.ts b/packages/stack/__tests__/decrypt-audit-forwarding.test.ts index 9f3c09288..bc7b7d236 100644 --- a/packages/stack/__tests__/decrypt-audit-forwarding.test.ts +++ b/packages/stack/__tests__/decrypt-audit-forwarding.test.ts @@ -12,7 +12,7 @@ * * Credential-free: protect-ffi is mocked, so there is no ZeroKMS round-trip. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Encryption } from '@/index' // A protect-ffi-shaped encrypted payload so the SDK's `isEncryptedPayload` @@ -67,13 +67,25 @@ const lastCiphertextLockContext = () => lastDecryptOpts().ciphertexts[0]?.lockContext let client: Awaited>> +let prevWorkspaceCrn: string | undefined beforeEach(async () => { vi.clearAllMocks() + prevWorkspaceCrn = process.env.CS_WORKSPACE_CRN process.env.CS_WORKSPACE_CRN = 'crn:ap-southeast-2.aws:test-workspace' client = await Encryption({ schemas: [users] }) }) +afterEach(() => { + // Restore the prior value so this suite doesn't leak env state into other + // Vitest suites sharing the worker. + if (prevWorkspaceCrn === undefined) { + delete process.env.CS_WORKSPACE_CRN + } else { + process.env.CS_WORKSPACE_CRN = prevWorkspaceCrn + } +}) + describe('typed v3 client: audit metadata forwards through decryptModel', () => { it('forwards .audit({ metadata }) as unverifiedContext (no lock context)', async () => { unwrap( diff --git a/packages/stack/src/encryption/index.ts b/packages/stack/src/encryption/index.ts index 968bea2e1..45ffe5a9a 100644 --- a/packages/stack/src/encryption/index.ts +++ b/packages/stack/src/encryption/index.ts @@ -937,7 +937,11 @@ export async function Encryption( // `eqlVersion: 2` over v3 schemas gets the nominal client for that deliberate, // low-level v2-wire migration path (the typed client cannot encrypt v3 columns // in v2 mode anyway). - return isV3Only && eqlVersion === 3 - ? typedClient(result.data, ...(schemas as unknown as readonly AnyV3Table[])) - : result.data + if (isV3Only && eqlVersion === 3) { + // biome-ignore lint/plugin: the runtime `isV3Only` guard (every schema has + // buildColumnKeyMap) proves these are AnyV3Table — the compiler can't see it. + const v3Schemas = schemas as unknown as readonly AnyV3Table[] + return typedClient(result.data, ...v3Schemas) + } + return result.data } diff --git a/packages/stack/src/encryption/operations/mapped-decrypt.ts b/packages/stack/src/encryption/operations/mapped-decrypt.ts index 378b30ab4..8aa0fddd3 100644 --- a/packages/stack/src/encryption/operations/mapped-decrypt.ts +++ b/packages/stack/src/encryption/operations/mapped-decrypt.ts @@ -81,7 +81,8 @@ export class MappedDecryptOperation extends EncryptionOperation { override async execute(): Promise> { if (!this.map) { - return this.unknownTableFailure + // Fresh Result so no two ops can alias (or mutate) a shared failure object. + return { failure: { ...this.unknownTableFailure.failure } } } const result = await this.underlying.execute() if (result.failure) { From 93286addf7a6698738772e36a7581d6e5b47279b Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 23 Jul 2026 15:08:58 +1000 Subject: [PATCH 4/4] =?UTF-8?q?fix(stack):=20address=20PR=20768=20review?= =?UTF-8?q?=20=E2=80=94=20stale=20decrypt-audit=20guidance,=20silent=20loc?= =?UTF-8?q?k-context=20re-bind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveDecryptResult: the inner comment and `logger.debug` message still said the typed client has no decrypt audit surface and told the reader to use `Encryption({ config: { eqlVersion: 3 } })`. Both shipped clients now carry `.audit()` on decrypt, so the branch only fires for a non-conforming custom client. Message rewritten to describe that, with a regression test asserting it names neither `eqlVersion` nor `EncryptionV3`. The same false claim had propagated to the resolve-decrypt test header, `dynamodb/types.ts`, a test literally named "though decrypt cannot carry it", and a comment in `stack-supabase` — all corrected. - MappedDecryptOperation.withLockContext: chaining a second lock context onto an already-bound op silently dropped it. The wrapper always exposes the method, unlike the nominal path where it is absent after binding, so the re-bind type-checks. It now throws. Verified no internal or sibling call site chains after a positional bind (stack-supabase calls `decryptModel(row)` one-arg then chains, so its underlying op is unbound). Tests cover both `decryptModel` and `bulkDecryptModels`. - Changeset bumped minor -> major. Making `Encryption({ schemas: [] })` return the typed client changes its return type AND adds `Date` reconstruction on the two-arg `decryptModel` for existing plain-`Encryption` v3 callers. The package already carried a major, so the released bump is unchanged — the changelog is just accurate now. - skills/stash-encryption documented `decryptModel`/`bulkDecryptModels` as returning `Promise>` and said only encrypt-side ops are chainable. It ships in the `stash` tarball, so that was wrong guidance in customer repos. Updated, including the one-or-the-other lock-context rule. --- .changeset/stack-audit-on-decrypt.md | 18 +++++++--- packages/stack-supabase/src/index.ts | 10 +++--- .../decrypt-audit-forwarding.test.ts | 22 ++++++++++++ .../dynamodb/encrypted-dynamodb-v3.test.ts | 10 +++--- .../dynamodb/resolve-decrypt.test.ts | 35 +++++++++++++++---- packages/stack/src/dynamodb/helpers.ts | 10 +++--- packages/stack/src/dynamodb/types.ts | 8 +++-- .../encryption/operations/mapped-decrypt.ts | 30 ++++++++++++---- skills/stash-encryption/SKILL.md | 6 ++-- 9 files changed, 112 insertions(+), 37 deletions(-) diff --git a/.changeset/stack-audit-on-decrypt.md b/.changeset/stack-audit-on-decrypt.md index d832b793d..dd21fd17c 100644 --- a/.changeset/stack-audit-on-decrypt.md +++ b/.changeset/stack-audit-on-decrypt.md @@ -1,5 +1,5 @@ --- -'@cipherstash/stack': minor +'@cipherstash/stack': major --- The typed EQL v3 client's `decryptModel` / `bulkDecryptModels` are now @@ -20,9 +20,19 @@ unchanged — the operation is still thenable to the same `Result`. This restore audited decrypt through the DynamoDB adapter (`encryptedDynamoDB(...).decryptModel`) for a v3 client, which previously had nowhere to carry the metadata. -`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. As part of this collapse +Chaining `.withLockContext()` onto a decrypt operation that already took a lock +context positionally (`decryptModel(item, table, lc).withLockContext(other)`) now +throws instead of silently keeping the first. Pass the lock context one way or +the other, not both. + +**Breaking:** `EncryptionV3` is now a deprecated, type-identical alias of +`Encryption`: `Encryption` is overloaded so an array of concrete EQL v3 tables +yields the same strongly-typed client. Use `Encryption` for new code. If you were +already passing EQL v3 tables to plain `Encryption`, you now receive the typed +client rather than the nominal one — its `decryptModel` / `bulkDecryptModels` +return type changes, and the two-argument form reconstructs `Date` columns from +`cast_as` instead of leaving them as ISO strings. Code that read those columns as +strings needs updating. As part of this collapse `EncryptionV3` no longer independently pins the wire format — like `Encryption`, it now honours an explicit `config.eqlVersion` (the retained migration escape hatch). The `eqlVersion` config field and the `@cipherstash/stack/schema` EQL v2 diff --git a/packages/stack-supabase/src/index.ts b/packages/stack-supabase/src/index.ts index ec9847261..0ec54e1f5 100644 --- a/packages/stack-supabase/src/index.ts +++ b/packages/stack-supabase/src/index.ts @@ -225,11 +225,11 @@ export async function encryptedSupabaseV3( } // 5. Build the raw (eqlVersion 3) encryption client from the merged tables. - // NB: `Encryption`, not `EncryptionV3` — the query builder consumes the raw - // chainable `EncryptionClient`, whereas `EncryptionV3` returns the typed - // wrapper whose `decryptModel` returns a plain Promise. Pass only - // tables that carry at least one encrypted column (`Encryption` requires a - // non-empty schema list). + // NB: the query builder consumes the raw chainable `EncryptionClient`, and + // calls `decryptModel(row)` with no table — the typed client degrades to + // nominal (passthrough) behaviour for that arity, so either shape works. + // Pass only tables that carry at least one encrypted column (`Encryption` + // requires a non-empty schema list). const encryptionSchemas = [...synth.tables.values()].filter( (t) => Object.keys(t.columnBuilders).length > 0, ) diff --git a/packages/stack/__tests__/decrypt-audit-forwarding.test.ts b/packages/stack/__tests__/decrypt-audit-forwarding.test.ts index bc7b7d236..3953d0a8b 100644 --- a/packages/stack/__tests__/decrypt-audit-forwarding.test.ts +++ b/packages/stack/__tests__/decrypt-audit-forwarding.test.ts @@ -139,6 +139,28 @@ describe('typed v3 client: audit metadata forwards through decryptModel', () => expect(lastCiphertextLockContext()).toEqual(IDENTITY_CLAIM) }) + it('throws when a second lock context is chained onto an already-bound op', () => { + // The wrapper always exposes `withLockContext`, so a positional bind + // followed by a chained one type-checks. Silently keeping the first would + // drop the caller's intent (and fail later at ZeroKMS with an opaque + // rejection); reject the re-bind at the call site instead. + const op = client.decryptModel({ email: enc() }, users, IDENTITY_CLAIM) + + expect(() => op.withLockContext({ identityClaim: ['other'] })).toThrow( + /already bound to a lock context/i, + ) + }) + + it('throws on a re-bind for bulkDecryptModels too', () => { + const op = client.bulkDecryptModels([{ email: enc() }], users, { + identityClaim: ['sub'], + }) + + expect(() => op.withLockContext(IDENTITY_CLAIM)).toThrow( + /already bound to a lock context/i, + ) + }) + it('forwards .audit({ metadata }) on bulkDecryptModels', async () => { unwrap( await client diff --git a/packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts b/packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts index c32b41f10..4f1f42096 100644 --- a/packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts +++ b/packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts @@ -624,7 +624,7 @@ describe('audit metadata with a v3 table', liveSuiteOptions, () => { expect(decrypted.data).toEqual(item) }) - it('is accepted on the typed client, though decrypt cannot carry it', async () => { + it('is carried on every operation of the typed client too', async () => { const item: User = { pk: 'user#15', email: 'ken@example.com' } const encrypted = await typedDynamo @@ -632,9 +632,11 @@ describe('audit metadata with a v3 table', liveSuiteOptions, () => { .audit({ metadata }) if (encrypted.failure) throw new Error(encrypted.failure.message) - // The typed client's `decryptModel` returns a plain promise with no audit - // surface. The chain must still resolve correctly — the metadata is simply - // not forwarded. Use the nominal client if decrypt audit matters. + // The typed client's `decryptModel` now returns a `MappedDecryptOperation`, + // so the metadata forwards to ZeroKMS here as it does on the nominal client. + // That forwarding is proven credential-free in + // `__tests__/decrypt-audit-forwarding.test.ts`; this live test asserts the + // round-trip still decrypts with the chain attached. const decrypted = await typedDynamo .decryptModel(encrypted.data, users) .audit({ metadata }) diff --git a/packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts b/packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts index 8d5eb2b1b..92044eb78 100644 --- a/packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts +++ b/packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts @@ -1,9 +1,11 @@ /** - * Pure unit tests for the two client-shape helpers that bridge the nominal - * `EncryptionClient` (chainable, carries `.audit()`) and the typed client from - * `EncryptionV3` (plain `Promise`). Both branches were previously only - * reachable through a live ZeroKMS decrypt; these move that assurance onto the - * pure CI lane. No credentials, no network. + * Pure unit tests for the two client-shape helpers behind the DynamoDB adapter's + * decrypt path. Both shipped clients — nominal `EncryptionClient` and the typed + * EQL v3 client (whose decrypt returns a `MappedDecryptOperation`) — are + * chainable and carry `.audit()`; the bare-promise branch remains only for a + * non-conforming custom client. Every branch was previously reachable only + * through a live ZeroKMS decrypt; these move that assurance onto the pure CI + * lane. No credentials, no network. */ import type { Result } from '@byteslice/result' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -22,7 +24,7 @@ afterEach(() => { }) describe('resolveDecryptResult', () => { - it('awaits a plain promise when the operation has no .audit (typed client)', async () => { + it('awaits a plain promise when the operation has no .audit (custom client)', async () => { const result = await resolveDecryptResult( Promise.resolve({ data: { x: 1 } }), { metadata: { ignored: true } }, @@ -85,6 +87,27 @@ describe('resolveDecryptResult', () => { } }) + it('does not blame the typed client in the dropped-metadata message', async () => { + // Both shipped clients now carry `.audit()` on decrypt, so this branch is + // reachable only from a non-conforming custom client. The message used to + // tell the reader to switch to `Encryption({ config: { eqlVersion: 3 } })` + // for audited decrypts, which is no longer true of any shipped client. + const spy = vi.spyOn(logger, 'debug').mockImplementation(() => {}) + + try { + await resolveDecryptResult(Promise.resolve({ data: { x: 1 } }), { + metadata: { m: 42 }, + }) + + const message = spy.mock.calls.at(-1)?.[0] as string + expect(message).not.toMatch(/eqlVersion/) + expect(message).not.toMatch(/EncryptionV3/) + expect(message).not.toMatch(/typed client/) + } finally { + spy.mockRestore() + } + }) + it('does not log about audit metadata when none is passed', async () => { const spy = vi.spyOn(logger, 'debug').mockImplementation(() => {}) diff --git a/packages/stack/src/dynamodb/helpers.ts b/packages/stack/src/dynamodb/helpers.ts index 67be28a68..38c62d099 100644 --- a/packages/stack/src/dynamodb/helpers.ts +++ b/packages/stack/src/dynamodb/helpers.ts @@ -103,12 +103,12 @@ export async function resolveDecryptResult( } if (typeof chainable?.audit !== 'function' && auditData.metadata) { - // The typed EncryptionV3 client returns a plain promise with no decrypt - // audit surface, so the metadata has nowhere to go. Make the drop - // observable rather than silent — use the nominal client for audited - // decrypts. + // Every client this package ships carries `.audit()` on decrypt, so this + // only fires for a custom client whose decrypt returns something else — + // there is then nowhere to put the metadata. Make the drop observable + // rather than silent. logger.debug( - 'DynamoDB: decrypt audit metadata ignored — the typed client has no decrypt audit surface; use Encryption({ config: { eqlVersion: 3 } }) for audited decrypts.', + "DynamoDB: decrypt audit metadata ignored — this client's decrypt does not return a chainable operation with .audit(). Audited decrypts need a client built with Encryption({ schemas }).", ) } diff --git a/packages/stack/src/dynamodb/types.ts b/packages/stack/src/dynamodb/types.ts index 9dfebb8cb..bee2e952d 100644 --- a/packages/stack/src/dynamodb/types.ts +++ b/packages/stack/src/dynamodb/types.ts @@ -69,9 +69,11 @@ type ChainableEncryptOperation = { * satisfy. The operation classes therefore cast to this shape at the call site * — the same split the Drizzle v3 operators use. * - * `decryptModel` is intentionally untyped in its return: the nominal client - * returns a chainable operation, the typed client a plain promise. See - * `resolveDecryptResult`. + * `decryptModel` is intentionally untyped in its return: both shipped clients + * return a chainable operation, but different classes of one (the nominal + * client's `DecryptModelOperation`, the typed client's `MappedDecryptOperation`), + * and a custom client may return something else entirely. See + * `resolveDecryptResult`, which normalises all three. */ export type CallableEncryptionClient = { encryptModel( diff --git a/packages/stack/src/encryption/operations/mapped-decrypt.ts b/packages/stack/src/encryption/operations/mapped-decrypt.ts index 8aa0fddd3..e3ee40b50 100644 --- a/packages/stack/src/encryption/operations/mapped-decrypt.ts +++ b/packages/stack/src/encryption/operations/mapped-decrypt.ts @@ -22,6 +22,10 @@ type UnderlyingDecryptOperation = EncryptionOperation & { export interface AuditableDecryptModelOperation extends EncryptionOperation { audit(config: AuditConfig): this + /** + * @throws if the operation already took a lock context positionally — bind it + * once, either positionally or by chaining. + */ withLockContext( lockContext: LockContextInput, ): AuditableDecryptModelOperation @@ -45,7 +49,10 @@ export interface AuditableDecryptModelOperation * audit data forward, and `.withLockContext().audit()` propagates because the * wrapper forwards `.audit()` onto the now-lock-bound underlying op. * - `.withLockContext()` rebuilds the wrapper over `underlying.withLockContext(lc)`, - * preserving the same `map` and unknown-table failure. + * preserving the same `map` and unknown-table failure. It throws if the + * underlying op is already lock-bound (a positional lock context followed by a + * chained one), because the wrapper — unlike the nominal path — still exposes + * the method after binding, so the second call would otherwise be dropped. * - `execute()` never throws: an unknown table (no `map`) returns the precomputed * `failure` Result, and `map` is a precomputed reconstructor — pure, no * `build()` — so it cannot reject the Result contract. @@ -71,12 +78,21 @@ export class MappedDecryptOperation extends EncryptionOperation { withLockContext( lockContext: LockContextInput, ): MappedDecryptOperation { - // A lock-bound underlying op exposes no `withLockContext`; there is nothing - // to re-bind, so keep the current underlying op. - const bound = this.underlying.withLockContext - ? this.underlying.withLockContext(lockContext) - : this.underlying - return new MappedDecryptOperation(bound, this.map, this.unknownTableFailure) + // A lock-bound underlying op exposes no `withLockContext` — it has already + // consumed its context. Unlike the nominal path, where the method is simply + // absent after binding, the wrapper always exposes it, so a second bind + // type-checks. Silently keeping the first would drop the caller's intent and + // surface later as an opaque ZeroKMS rejection; reject it here instead. + if (!this.underlying.withLockContext) { + throw new Error( + '[encryption]: this decrypt operation is already bound to a lock context. Pass the lock context positionally OR chain .withLockContext() — not both.', + ) + } + return new MappedDecryptOperation( + this.underlying.withLockContext(lockContext), + this.map, + this.unknownTableFailure, + ) } override async execute(): Promise> { diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 5b440c8f0..39028c702 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -1009,14 +1009,14 @@ Useful when the backfill needs to run in a worker, on a schedule, or alongside a | `encryptQuery` | `(plaintext, { table, column, queryType?, returnType? })` — queryable columns only; `queryType` constrained to the column's capabilities | `EncryptQueryOperation` | | `encryptQuery` | `(terms: readonly ScalarQueryTerm[])` — batch form | `BatchEncryptQueryOperation` | | `encryptModel` | `(model, table)` — schema fields validated against inferred plaintext types | `EncryptModelOperation>` | -| `decryptModel` | `(model, table, lockContext?)` | `Promise, EncryptionError>>` | +| `decryptModel` | `(model, table, lockContext?)` | `AuditableDecryptModelOperation>` | | `bulkEncryptModels` | `(models, table)` | `BulkEncryptModelsOperation>` | -| `bulkDecryptModels` | `(models, table, lockContext?)` | `Promise[], EncryptionError>>` | +| `bulkDecryptModels` | `(models, table, lockContext?)` | `AuditableDecryptModelOperation[]>` | | `bulkEncrypt` | `(plaintexts, { column, table })` — parity passthrough | `BulkEncryptOperation` | | `bulkDecrypt` | `(encryptedPayloads)` — parity passthrough | `BulkDecryptOperation` | | `getEncryptConfig` | `()` | The client's encrypt config | -The encrypt-side operations are thenable (awaitable) and support `.withLockContext()` and `.audit()` chaining; `decryptModel`/`bulkDecryptModels` return plain promises and take the lock context as an argument. +All of these operations are thenable (awaitable) and support `.withLockContext()` and `.audit()` chaining — including `decryptModel`/`bulkDecryptModels`, which also accept the lock context as a third argument. Use one or the other: chaining `.withLockContext()` onto a decrypt that already took a positional lock context throws. ### Schema Builders